Skip to content

The CLI

pinrail is the command an agent uses to ask a person before it acts. It sends a review to the Pinrail app on your machine, waits while you decide, and prints the decision: as markdown for an agent to read, or as JSON for a script to branch on.

Terminal window
pinrail submit code-review --title "Dedup tickets on save — round 1" \
--origin repo=acme/api,workflow=review,ref=42 \
--data proposals.json --wait
What the agent reads
# Dedup tickets on save — round 1
review · acme/api · review · 42
Decided by alice at 2026-09-10 09:00 · 1 rejected
## Proposals
- **#18 rejected** `lib/acme/tickets.ex:149` — reversing twice is a no-op with a cost (major)
> don't nitpick
Undecided: #19, #20

The CLI holds no state and makes no decisions of its own. The answer goes to stdout, errors and warnings to stderr, and every outcome has an exit code, so it is safe to call from any shell, CI job or agent harness.

You do not have to explain Pinrail to your agent. The pinrail docs command prints two guides, written for an agent at work: one on using Pinrail, and one on building a plugin, which an agent reads only when its task is to build one. The guides come from the Pinrail you have installed, so they always match it. They are the same files as the pinrail skill that Settings › Agents adds to an agent, so an agent with the skill reads them as files.

Terminal window
pinrail docs # using Pinrail: asking, the answer, choosing a plugin
pinrail docs building # building a plugin
pinrail docs --tree # both, with what each covers

pinrail docs covers what Pinrail is, the command an agent runs to ask, its options and exit codes, what to do with the answer, files and revisions, choosing a plugin, and writing a standing rule for itself. pinrail docs building goes from a new plugin to a review in the person’s inbox: the files, the decision, the view’s contract, the dev shell, tests, the design language, settings and keys, and frameworks. pinrail --help points there, and so does a new plugin’s README.md.

The other commands follow the same approach. pinrail plugins lists the plugins a line each before describe gives one in full; pinrail plugins new ends with the next commands to run; submit says where the review is. An agent starts from pinrail docs, or from the plugin its instructions name, and finds the rest as it goes.

Two steps tell an agent everything it needs to ask through Pinrail: first which plugin fits, then that plugin in full.

Terminal window
pinrail plugins # every plugin, a line each
pinrail plugins describe code-review # one plugin, in full

pinrail plugins lists each plugin in a line with what it is, when to use it, in the plugin author’s words, and the files it takes, beside its version, where it comes from and whether it works.

describe gives one plugin in full:

  • what the plugin is for, and when to use it;
  • the payload schema, and an example payload that passes it;
  • the decision schema, which describes decision.data in the review that comes back.

--payload-schema, --example and --decision-schema each print one of these parts alone, as JSON, for a tool or to start a payload with --example > payload.json. An agent that reads the decision as markdown does not need the decision schema. One that processes the decision with --json does, and the JSON output always includes it.

Like submit, describe can start a server when none is running, if PINRAIL_SERVER_CMD says how (see Finding the app). For a plugin that is installed but broken, it says what is wrong and how to check it.

Terminal window
pinrail submit <plugin> --title <title> --data <file> [--wait]
FlagMeaning
--titleRequired. What the review is about, as it will appear in your inbox.
--data <json>The payload, as JSON: inline, such as --data '{"groups": []}', in a file, or - for stdin.
--attach <path>[=<name>]A file to send beside the payload. Only a plugin that declares files accepts them. Repeat for more. See Sending files.
--waitBlock until the review is decided or ends, then print it. Without it, submit prints the new review and returns at once.
--dry-runRun every check a submission gets and create no review. Exits 0 when it would be accepted, 2 with the violations.
--request <json>The whole request as JSON, inline or in a file. See The whole request in one file.
--sampleSend the plugin’s sample, a review it ships to show what it looks like, in place of a payload. --title and --origin still apply. See A plugin’s sample.
--jsonPrint the review as JSON instead of markdown, for a script.
--originWhere the review comes from: repo=…,workflow=…,run_id=…,ref=…,url=…. The app groups reviews by project and links back to url, which must be an http or https address. Inside a git checkout, repo and ref default to the remote’s owner/name and the current branch; outside one, give repo a short name for the project. Any other key is dropped, with a warning on stderr.
--revises <id>This review is a new round of an earlier one. It must revise the latest round, with the same plugin. A revised round that is still pending is withdrawn.
--timeout <seconds>With --wait: give up after this long, exit 4, and leave the review pending.
--decision-out <file>Also write the decision’s data, as JSON, to a file.
--expires-atClose the review if nobody decides by then.
--requested-byWho is asking, shown on the review, with the agent’s icon when the app knows it. Defaults to PINRAIL_REQUESTED_BY. Otherwise it is the coding agent the command runs under, as AI_AGENT or AGENT names it when either is set, or as found from the variables that antigravity, claude-code, codex, cursor, grok and opencode set. AGENT counts only when it names an agent Pinrail knows, which also includes kimi. Without any of these, it is pinrail-cli. Name the job or role with --origin workflow=….

Submitting the same review again while the first is still pending does not create a second one: the command answers the review already waiting, with its id. An agent that runs the command a second time, for example after its first attempt was stopped, waits on the same review, and the person decides it once.

Instead of flags, the agent can write the whole request as one JSON file and pass it with --request (or --request - to read stdin):

request.json
{
"plugin": "list",
"title": "Sentry triage",
"origin": { "repo": "acme/api", "ref": "main" },
"payload": { "groups": [ … ] }
}
Terminal window
pinrail submit --request request.json --wait

The file takes the same keys as the flags: plugin, title, payload, origin, revises, expires_at, requested_by, and attachments, a map of name to path relative to the file. Any flag given as well overrides the file’s key, and --data replaces its payload. So a new round is the same file with one more flag:

Terminal window
pinrail submit --request request.json --revises <id> --wait

A plugin can ship a sample review. Send it to see what the plugin looks like, or to try Pinrail end to end, without writing a payload:

Terminal window
pinrail submit list --sample --wait

The review arrives like any other; decide it and the command prints your decision, as an agent would read it. pinrail plugins describe <plugin> says "sample": true for a plugin that has one, and a plugin without one is an error that says so. The same sample can be sent with Send a sample in the plugin’s details in Settings › Plugins.

Terminal window
pinrail submit code-review --title "Dedup tickets on save" --data review.json --dry-run

A dry run checks the title, the origin and the payload against the plugin’s schema, exactly as a submission would, and nothing reaches the inbox. When something is wrong it exits 2 and prints each violation with a JSON pointer to it, such as /payload/proposals/0/line, so the agent can fix the payload before a person sees it.

Some plugins take files beside the payload. The plugin’s payload schema says where each file goes, and pinrail plugins describe <name> shows it with the kinds and sizes the plugin accepts; a plugin that declares none refuses a submission with files. Build the payload as the schema says, and send each file it names with --attach. A schema marks a file’s place with an object whose one key, $attachment, holds the file’s name. For the Image review plugin:

Terminal window
pinrail submit image --title "Empty inbox illustration — round 1" --data images.json \
--attach out/paper-plane.png --attach out/v2.png=mailbox.png --wait
images.json
{
"images": [
{
"id": "A",
"name": "Paper plane",
"file": { "$attachment": "paper-plane.png" }
}
]
}

A file keeps its own name unless another follows =. The CLI checks the whole submission before it uploads anything, so a refused one moves nothing. It then uploads only the files the app does not have yet, which makes a new round cheap: only the files that changed are sent again. A file may be up to 100 MB, and a review may carry 32 files adding up to 512 MB, unless the plugin sets lower limits.

The files come back with pinrail attachments:

Terminal window
pinrail attachments list <id> # name, size, media type and hash of each
pinrail attachments get <id> paper-plane.png -o paper-plane.png # save one; -o - writes it to stdout

submit --wait is the usual way: one command that submits, waits and prints. When the waiting has to happen somewhere else, submit without --wait and wait later, from any process:

Terminal window
id=$(pinrail submit list --title "Nightly cleanup" --data items.json --json | jq -r .id)
# …later, or elsewhere
pinrail wait "$id"

Waiting survives the app restarting. wait polls the app and retries when the connection drops, so if the app restarts, the wait resumes a few seconds later and the review is not lost.

CodeMeaning
0Done. For wait and submit --wait: the review was decided.
1Error: bad arguments, the app could not be reached, a file could not be read or written, or the app failed on its side. wait and submit --wait keep waiting through an error inside the app until it answers again.
2The app refused the request, for example a payload the plugin’s schema rejects, or a folder that plugins check would not install. Why is on stderr. It lists each refused field with its reason, or, with --json, gives the app’s JSON answer.
3The review was withdrawn by the agent, or expired, before anyone decided.
4--timeout ran out. The review is still pending.
5The person discarded the review: stop the work it was gating, and do not ask again.

Markdown is the default, because an agent reads it: the title, where the review came from, who decided and when, a tally, your note, then the decision, each plugin rendering its decision in a way that suits it. Every other command prints markdown too: a listing a line per review, a plugin’s description, what a plugin command did.

JSON is for a script or a tool that processes the result rather than reads it: the whole review with the decision attached, to read with jq or anything else.

SetEffect
--jsonJSON instead of markdown.
PINRAIL_JSON=1JSON for every command in this environment, such as a CI job.
--prettyIndented JSON; with --json.
--verbose, -vAlso say on stderr what happened along the way: the origin read from git, the server started, the files uploaded. PINRAIL_VERBOSE=1 sets it for every command.

--decision-out always writes JSON.

CommandWhat it does
pinrail submit <plugin>Submit a review.
pinrail wait <id>Block until a review leaves pending, then print it.
pinrail show <id>Print a review with its payload and decision.
pinrail listThe pending reviews of the project you are in, newest first; --all for every review. Filter with --status, --repo, --workflow, --ref, --plugin and --q.
pinrail rounds <id>Every round of a review, oldest first.
pinrail events <id>A review’s event log.
pinrail open <id> [--browser]Open a review in the app, or with --browser its preview, for building a plugin.
pinrail attachments list <id>The files a review carries.
pinrail attachments get <id> <name>Save a file a review carries. -o says where; it never overwrites without --force.
pinrail decide <id>Record a decision from a script. The app is the usual way.
pinrail withdraw <id>Take a pending review back. Its waiter exits 3.
pinrail discard <id>Discard a pending review. Its waiter exits 5.
pinrail export <dir>Write every review as JSON files under a directory.
pinrail serveStart the app’s server if it is not running, and print its URL.
pinrail pluginsList installed plugins, as a table or with --json as data, and install or remove them.
pinrail plugins new <name> [--template plain|typescript|react] [--playwright] [--link]A new plugin: manifest, schemas, a sample, and a view with the SDK’s types. The plain view needs no build or npm; typescript (or ts) and react write a view built by Vite. --playwright adds a first test, and --link installs a plain plugin right away. See Writing a plugin.
pinrail plugins check [dir]What the app would make of a plugin folder, installing nothing: why it would refuse it, and each feature it would drop. It also checks the plugin’s recorded decisions in fixtures/ (see Building with a framework). Exits 0 when the app would take the plugin and every recorded decision checks out, 2 when not. Build a plugin that has a build step first, so that its view is there. It needs no running app.
pinrail plugins schema [manifest|attachment]Prints one of the plugin SDK’s JSON Schemas: the manifest’s, which every manifest.json is checked against, or the attachment schema, which a payload schema copies for a field that names a file. It needs no running app.
pinrail plugins describe <name>What an agent needs to ask with a plugin; --payload-schema, --example or --decision-schema for one part alone. See Learning what to ask.
pinrail docs [path]The briefs for an agent: how to ask, and how to build a plugin. --tree lists them all.

pinrail <command> --help lists every flag, and the CLI reference has them all.

The CLI talks to the server the Pinrail app runs on your machine, and finds it on its own, in this order:

  1. --url, or PINRAIL_URL in the environment.
  2. The server.json the running app writes into its data directory: PINRAIL_DATA_DIR, else $XDG_DATA_HOME/pinrail, else ~/.local/share/pinrail.
  3. http://127.0.0.1:4747, or the port in PINRAIL_PORT.

When nothing answers, submit, serve, plugins, plugins describe and plugins new --link can start a server for you, if you say how with PINRAIL_SERVER_CMD. The app runs its server without a window with --headless:

Terminal window
export PINRAIL_SERVER_CMD='/Applications/Pinrail.app/Contents/MacOS/Pinrail --headless'

A server started this way uses the same data directory as the app, and only one of them can have it open at a time. While the headless server runs, opening the app shows which process to stop first.

submit --no-start fails instead of starting one.

VariableUsed for
PINRAIL_URLThe server to talk to, ahead of anything the CLI finds.
PINRAIL_DATA_DIRWhere the running server’s server.json and server.log are.
PINRAIL_PORTThe port to try when nothing is advertised.
PINRAIL_SERVER_CMDHow to start a server when none is running.
PINRAIL_TIMEOUTHow many seconds wait and submit --wait wait, when --timeout is not given. Set it once below your command time limit.
PINRAIL_JSON1 for JSON output from every command, as --json gives.
PINRAIL_VERBOSE1 for the notes that --verbose prints, from every command.
PINRAIL_REQUESTED_BYWho is asking, shown on every review, ahead of the agent the command finds itself running under.