Scripting and embedding

Four ways to drive the agent from something other than a person at a terminal.

Line mode: one turn per line

With piped standard input, revolve runs one turn per input line and prints to standard output.

printf '/help\nWhat is 2+2?\n' | revolve

Nobody can answer a permission prompt here, so an uncovered command is denied with a note and the model is told it was refused. --allow-all runs it anyway, and never overrides a deny rule.

JSON mode: one turn, one object

--print <message> --json runs exactly one turn and writes a single JSON object to standard output. Nothing else goes there, so | jq .answer needs no filtering: startup notes, tool calls, token counts, errors and any permission prompt all go to standard error, where a script reads or discards them.

KeyMeaning
protocolThe version of the vocabulary, the same number RPC mode reports
answerEverything the model said, joined and trimmed
tool_callsThe tool calls the turn made, in order, each {"text": …}
outcomedone, cancelled, limit, or error
messageEmpty for done and cancelled; otherwise the limit that stopped the turn or the error that ended it
input_tokens, output_tokensWhat the turn spent, children included
nameThe session name, or null for an unnamed session
cwd, modelThe directory the turn ran in and the model that ran it
$ revolve -p "What is 2+2? Answer with the number only." --json math 2>/dev/null | jq .
{
  "protocol": 25,
  "name": "math",
  "cwd": "/home/x/project",
  "model": "anthropic:claude-haiku-4-5",
  "outcome": "done",
  "message": "",
  "answer": "4",
  "tool_calls": [],
  "input_tokens": 19109,
  "output_tokens": 5
}

outcome is the field to branch on. limit means a turn budget stopped the turn where it stood: the answer may be missing, half-written, or wrong about its own completeness, and reading it as a finished result is the mistake this field exists to prevent. error means the turn never produced a result — a missing config, a provider that refused, a session that died — and the process exits non-zero as well, so $? and the object agree.

A session name works here as everywhere else, which is what lets a script run several turns in one conversation.

RPC mode: a conversation over a pipe

--rpc speaks one JSON object per line in and one per line out. It is what an editor or a wrapper program talks to. The first line out is {"event":"ready","protocol":25}, and protocol is bumped whenever an event name or field set changes.

Requests, on standard input

RequestEffect
{"type":"user_message","text":"…"}Send text to the agent. A text starting with / is a chat command, exactly as elsewhere
{"type":"agent_message","from":"…","from_directory":"…","text":"…","waiting":<bool>}Deliver a message from another agent. It arrives marked as foreign, is never a chat command, from_directory is where the sender works, so a coordinator can tell which repository answered rather than only which name, and waiting says whether the sender is blocked on the answer. The session answers with a receipt line saying whether it took the message up now or queued it behind a running turn. Every interactive session accepts it, because each one binds a socket beside its front end; -p, --json and --rpc do not, being one turn or a server the caller already owns
{"type":"cancel"}Interrupt the running turn; what the model already said stays in the log
{"type":"set_model","model":"…"}Point the session at another model, by the name the /model command accepts. It takes effect on the next turn, keeps the socket, name and history, and is written into the session file so a resume opens on it. A switch to a smaller context window trims the history first instead of sending it over-long and learning from a 400
{"type":"cancel_child","name":"…"}Stop one background child by the short name its tool calls carry, leaving the turn and its other children running
{"type":"permission","id":<n>,"response":"…"}Answer the ask_user prompt with that id. The response is one of allow_once, allow_command, allow_any_variant, allow_by_agent, deny, deny_command, deny_any_variant or deny_by_agent: four answers for yes and the same four for no, where command writes the narrow rule the prompt named, any_variant the broad one, and by_agent writes nothing itself — the call runs once or is refused, and the model is told to propose a rule with RequestPermission, which asks you again with the rule spelled out. An unknown name is an error line naming all eight
{"type":"question","id":<n>,"answers":[{"chosen":["…"],"text":"…"}]}Answer an ask_question form with that id: one answer object per question, in order, each carrying the option labels chosen and the free text typed
{"type":"inbox_answer","id":<n>,"answer":{"chosen":["…"],"text":"…"}}Answer one parked inbox_question by its id. Nothing is blocked on it, so it may arrive turns later or not at all
{"type":"shutdown"}Stop and exit

Events, on standard output

EventFieldsMeaning
readyprotocolThe mode is up, and this is the version of the vocabulary
text_deltatextA piece of the answer as it streams in
thought_deltatextA piece of the model's reasoning, for a model that reports one
tool_calltext, detail, depth, agentA tool the model called. detail carries what an edit replaces — removed lines prefixed -, added ones +, a whole new file as additions — and is null for every other tool. depth is 0 for the session's own call and 1 for one a child made, and agent names that child so two running at once can be told apart, null for the session itself
infotextA note for the user: startup notes, token counts, turn cancelled
errormessageThe turn failed or the request was malformed. The session stays alive
user_texttextA user message, on resume and on replay
model_changedmodel/model switched the model
model_choicesquery, names/model matched several models or none exactly; the client offers the choice
session_choicesnames/resume was typed without a name; the client offers the sessions of this directory, most recent first, and sends the pick back as /resume <name>
history_resetEverything shown so far belongs to a conversation this session has left. A client drops what it holds; what follows is the replay of the session that was resumed
context_usagetextHow full the context window is after the turn
rate_limitsfive_hour, seven_dayWhat the provider reported about the account's windows; each is null or {percent, resets_at}
todo_listtodosThe model's own step list, replaced whole; each item {content, status} with status one of pending, in_progress, completed
session_titletitleThe session was named
goal_linegoal, textThe standing goal in one labeled line, or null, with text carrying the bare objective for a client that draws its own label. Sent once at startup and again whenever it changes
peer_messagepeer, text, incomingA message from another agent arrived, or was sent to one
receiptdeliveryThe answer to an agent_message request, sent to the client that made it. delivered means the session was idle and takes the message up now; queued means a turn is in flight and it reads the message when that turn ends, so a sender can tell "the peer is working on it" from "the peer will get to it"
agent_startedname, cwdThe session started a peer agent as its own process, under that name and in that directory
child_progressagent, prompt, step, doneA child agent moved: which child, the task it was given, how many steps it has taken, and whether it has finished. It is how a background fan-out stays followable without its tool calls being mistaken for the session's
ask_questionid, questionsThe model asked one or more questions and the turn is blocked until a question request answers this id. Each entry is {prompt, options, multi, preview}, an option being {label, context}; a question with no options takes free text only, multi allows several labels at once, and preview asks the client to show the composed answer before it is sent
inbox_questionid, question, from_directory, from_agent, timestamp, kind, priority, task, waitA question parked in the inbox rather than asked: nothing is blocked on it, the turn went on, and an inbox_answer request may answer it whenever. from_agent and from_directory say which session in which repository is waiting on it. kind is question, blocker or note — a note wants no answer and closes when it has been read; task names the task file the entry came from, and answering such an entry writes the answer into that file; the three of them plus priority are what the AskInbox call that parked the entry set, and default to a question of no priority belonging to no task; wait is a duration or a date the user set to put the entry aside, and while it has not run out the entry stays on disk and out of the listing, exactly the way a task header's wait works
ask_userid, prompt, ruleA permission prompt. The turn is blocked until a permission request answers this id. rule is {"narrow":"<text>","broad":"<text>"}, the two rules this prompt's four answers a row would write — git commit and any git command, or this file and any file Read reaches — and it is null for a prompt that only takes yes or no, where the rule answers mean nothing
retryingattempt, of, wait_seconds, discarded, reasonThe request failed and is being repeated. discarded true means text already sent as text_delta belongs to a dead attempt and has to be thrown away
turn_startedA turn has begun
turn_outcomeoutcome, message, input_tokens, output_tokensHow the turn ended and what it spent. limit means a budget stopped it mid-task, which says nothing about whether the work succeeded
turn_endedThe turn is over and the next user_message may be sent
quit/quit was run; no further events follow
printf '{"type":"user_message","text":"What is 2+2? Answer with the number only."}\n' | revolve --rpc
{"event":"ready","protocol":25}
{"event":"info","text":"context: 33 kB per turn"}
{"event":"text_delta","text":"4"}
{"event":"context_usage","text":"context 17k/60k (28%)"}
{"event":"info","text":"17229 in, 5 out"}
{"event":"turn_outcome","outcome":"done","message":"","input_tokens":17229,"output_tokens":5}
{"event":"turn_ended"}

The round trip to get right is ask_user: read the id out of the event and send a permission request carrying that same id. A client that never answers deadlocks on the first tool call that asks. Ids count up from 1 for the life of the process and are never reused. Answering an id that is not open is an error line rather than a fatal one, and so is any other malformed request — a bad line never kills the session.

Closing standard input is not the same as shutdown: the mode stops reading requests but keeps emitting until the running turn has ended, so a plain pipe works for a script. An unanswered prompt is denied at that point rather than hanging forever.

Because prompts reach the client, RPC mode counts as interactive in the sense the permission engine means, the way the terminal does and the line mode does not.

Detached sessions

A session normally lives and dies with its front end. revolve detach starts one that does not: the agent runs as its own process, listening on a unix socket, and a front end attaches whenever there is someone to watch.

revolve detach review          # start it in the background and return to the shell
revolve agents                 # what exists, and what is running behind it
revolve attach review          # bring the terminal to it
revolve attach review --rpc    # watch the same session raw, as JSON lines
revolve stop review            # stop that one; stop --all stops every running session

The socket lives in $XDG_RUNTIME_DIR/task-agent/, or next to the session files when that variable is unset, and is removed when the session stops. revolve agents lists the named sessions of this directory — name, model, when the session file was last written, whether a process answers its socket — followed by every running session of any other directory. Liveness is decided by connecting, so a socket file with nobody behind it reads as stopped and is deleted on the spot.

A session that dies is started again by whoever started it. The process that spawned a detached session — an agent that called StartAgent, or the window that opened it — waits on that process, and an abnormal exit (a panic, or a kill by the OOM killer) is answered by starting it again on its own session after 250 ms, then a second, then five seconds. After the third try it is left down. A session that exits normally, including one ended by revolve stop, is never started again, and neither is one whose socket another process has taken over in the meantime — the socket is the lease, since bind refuses an address that still answers. Every restart writes a line into that session's own log beside its socket, so a session that came back says so. revolve detach returns to the shell and leaves no supervisor behind, and the cron line revolve schedule writes is what brings a session back that nobody was watching.

A detached session speaks the same vocabulary as RPC mode, one connection per client, so anything that can drive --rpc can drive a detached session by connecting to its socket. Several clients may attach at once and every event goes to all of them; closing standard input turns a watcher read-only instead of ending it, so a script can tail a session it is not driving.

The snapshot

What a socket adds over a pipe is what a client gets when it arrives in the middle of things. Right after ready, the session sends one snapshot line, because a client arriving mid-conversation cannot infer a state from a stream of edges.

FieldMeaning
protocolThe version of the vocabulary, as in ready
name, cwd, modelWhich session this is, where it works, what it is running on
turn_in_flightWhether a turn is running right now
eventsThe conversation so far as event objects, up to a bounded tail, with consecutive text_deltas merged so the tail is not spent on single tokens
pendingThe permission prompts still waiting for an answer, each {id, prompt, program, offer_global, proposal} — the same fields the ask_user event carries, so a client that arrives late is offered the same answers as one that was there
pending_questionsThe ask_question forms still open, each {id, questions} with the questions exactly as the event carries them. A turn is blocked on each of these
pending_inboxThe parked inbox questions nobody has answered, each {id, question, from_directory, from_agent, timestamp, kind, priority, task, wait}. Nothing is blocked on these; they are what a client shows as work waiting for you rather than for the session

Everything after that is the live stream. A client that disconnects changes nothing: a turn in flight runs to its end and its events stay in the tail for whoever attaches next.

The pending prompts are the point of it. A permission prompt has to reach a human, and no rule and no --allow-all may answer in his place — so with nobody attached the prompt is queued and the turn blocks there for as long as it takes. It reaches every client attached when it is raised and waits in pending for every client that arrives later. Nothing expires and nothing is denied on a timer. A session stopped while a turn was running says so with an error line before it quits.

Embedding it in a program

The agent is a library crate of its own, so a program can hold it instead of shelling out to it. The workspace has three members: task-agent in core/ is the agent with its tools, providers, sessions and every terminal-free front end; revolve in tui/ is the terminal binary; revolve-gui in gui/ is the window. A host depends on task-agent alone and therefore links no terminal library at all — ratatui, crossterm and idet are named nowhere in core/, which is a rule the dependency direction enforces rather than a habit.

task_agent::agent::Agent takes user input over an mpsc channel and answers with AgentEvents — the same seam every front end sits behind — and task_agent::files::Files is the sandbox the tools reach the disk through. The window is the proof that the seam holds: revolve-gui owns no agent at all, it starts a detached session and attaches to it, so everything it knows arrives as an AgentEvent and everything it says leaves as a String.

A host that already owns the text — an editor with unsaved buffers — hands in a FileHost rather than letting Files read and write the disk behind its back:

pub trait FileHost: Send + Sync {
    fn read(&self, path: &Path) -> Option<String>;
    fn write(&self, path: &Path, content: &str) -> Option<Result<(), FileError>>;
    fn editor_info(&self) -> Option<String> { None }
}

None means "not mine" and the disk answers, which is what makes partial ownership work: a host holds the documents the user has open and stays out of the way for everything else. Reads, writes, the content hash guarding Edit against unread files, and the Grep walk all go through it, so the model sees what the user sees rather than the last saved version. Files::set_host installs one and Files::set_root moves the sandbox at runtime, for a host whose project changes while the process keeps running. A host that answers editor_info also gains the EditorInfo tool, offered to the model only when a host actually answers it.

A host that takes a write has to put the text on the disk as well, not only into its own buffer, because the agent verifies its own work by running the build and the compiler reads files.