Protocol

A console that drives a Fisk AI agent lives in its own repository and cannot read this code. The pages here carry the routes, the request bodies, the responses and the status codes in enough detail to write such a client against them.

Explore

Subsections of Protocol

Web channel

The web channel answers a browser over HTTP. A page POSTs a turn, the run streams back on the response, and the thread the page named is one conversation for as long as the page keeps posting to it. Two frontend protocols are mounted, the Vercel AI SDK UI message stream and AG-UI, beside an agent card and a session API that both formats share.

Note

The channel is available since Version0.0.6.

Warning

Nothing on these routes is authenticated and every route reads and writes this agent’s conversations. A listener reachable from anywhere but loopback needs an authenticating proxy in front of it.

Turning it on

identity: nats-ops
application_path: /usr/local/bin/nats
system_prompt: |
  You operate NATS.
llm:
  model: claude-sonnet-5
expose:
  agent:
    web:
      listen: 127.0.0.1:8080
      base_path: /fisk/v1
      origins:
        - http://localhost:5173

The presence of expose.agent.web is the switch. listen defaults to 127.0.0.1:8080, base_path to /fisk/v1, and origins is required. identity, system_prompt and llm.model are required beside the block: a web turn runs the whole agent loop, and the identity is hashed into every journal this channel writes.

A page served over https cannot call an http endpoint, so a deployment naming an https:// origin puts a TLS proxy in front of this listener. That proxy also authenticates the caller.

Routes

Every route sits under base_path. The paths below assume the default.

MethodPathAnswers
POST/fisk/v1/vercelone turn as an AI SDK UI message stream
GET/fisk/v1/vercel/sessions/{id}a stored conversation as an AI SDK UI message stream
POST/fisk/v1/aguione turn as AG-UI events
GET/fisk/v1/agui/sessions/{id}a stored conversation as AG-UI events
GET/fisk/v1/cardthe agent card as JSON
GET/fisk/v1/sessionsstored conversations, a page or the ids you name
DELETE/fisk/v1/sessions/{id}nothing, with 204

The status codes are the same across every route, and a refusal body is plain text.

CodeWhen
200a turn, a stored conversation, the card or a listing
204a deleted conversation, and a CORS preflight
400a body the format could not decode, a turn naming no thread or carrying neither a prompt nor an answer, an answer naming no call, an AG-UI resume array naming no interrupt this agent raised, an answer payload of the wrong shape, a bad limit, a cursor this agent did not mint, more than 50 session ids, id named together with limit or cursor
403an Origin outside the configured list, or a Host that does not name the loopback listener on its bound port
404an answer for a thread holding no conversation, a session this channel does not hold, an unmounted path
405a method the route does not answer
409a second turn on a conversation already running
500a card that cannot be built, or stored conversations that cannot be listed or read
503a draining worker or one already running as many turns as it can, both carrying Retry-After; a turn caught by a shutdown after it was admitted, or one the worker abandoned, carrying none

Request bodies are capped at 1 MiB and a larger one is refused as a body that could not be decoded.

A turn that fails after its response head is written stays a 200, and the failure goes out as an error part or an AG-UI run-error event.

What the version in the base path means

Nothing in the channel reads v1. It is a segment of a default prefix an operator replaces whole, and it marks the shape of the routes below it: their paths and methods, the fields on the card, the fields on a session row, and what each status code means. Adding a field or a route leaves it where it is. Removing or renaming one, or changing what a code means, moves it.

The format endpoints carry their own protocol versions. The AI SDK stream answers with x-vercel-ai-ui-message-stream: v1, and AG-UI sends no version header.

Taking a turn

A turn is one POST. The request names the thread and carries a prompt, an answer to the question the last turn ended on, or both.

{
  "id": "thread-42",
  "messages": [
    {
      "id": "msg-1",
      "role": "user",
      "parts": [{"type": "text", "text": "list the streams"}]
    }
  ]
}
{
  "threadId": "thread-42",
  "runId": "run-1",
  "messages": [
    {"id": "msg-1", "role": "user", "content": "list the streams"}
  ]
}

The thread id is the page’s own name for the conversation. It is hashed with the serving identity into the session id the journal is written under, so a page reaches only the conversations this agent minted for it, and a thread id belonging to a Slack conversation opens a new conversation of its own instead.

Both endpoints answer with server-sent events. The AI SDK stream sends one data: line per stream part and ends the body with data: [DONE]. AG-UI opens with RUN_STARTED and a fisk.capabilities custom event, sends one event per AG-UI event, and finishes with RUN_FINISHED or RUN_ERROR. Both set content-type: text/event-stream, cache-control: no-cache, connection: keep-alive and x-accel-buffering: no, and both flush after every event.

The response stays open for as long as the run does, whether or not the page is still reading. A closed tab does not cancel a conversation, and the answer is in the journal either way.

The conversation belongs to the worker

The AI SDK’s documented server contract has the server rebuild the conversation from the messages array the client sent. This channel reads the last message of that array, takes it as the person’s turn when it is the person’s own, and takes everything else from its own journal. AG-UI is read the same way: the last message of the thread, and nothing else the client says about the conversation.

A stock frontend works, since it sends the whole history and the worker ignores it. Anything that depends on the client’s history being authoritative behaves differently:

  • regenerate resends the same body and starts a new turn rather than replacing the last one
  • edit-and-resend posts the edited text as the next turn, leaving the original where it is in the journal
  • deleting a message on the client removes it from the page and from nothing else

A console that wants those verbs has to reach them through the session endpoints or do without them.

A question ends the turn

A page reading a response cannot answer until that response is over. So a question the run asks is the last thing on the response that ends the turn, the tool call it guards is left unanswered, and the answer arrives on the page’s next POST, which resumes the conversation and dispatches that same call again.

The questions are the confirm gate’s approval of a command that has not run, and the three human-in-the-loop tools ask_human_confirm, ask_human_select and ask_human_input. An answer names the tool call rather than a question id, because the question is asked again under a new id on every resume, and the call is the one thing both ends agree an answer is about.

Nothing persists a question or an answer. The answer is held for the one turn that carries it, so any worker can serve the turn that answers a question another worker asked.

A POST that carries a prompt while a question is outstanding does not deliver it. The resume dispatches the gated call, the question is put again, and the turn ends without the conversation reaching a point that takes a user message. The AI SDK stream reports that as an error part, AG-UI as a fisk.prompt_not_taken custom event, and the page has to send the message again once the question is answered.

A request that carries an answer and a typed message delivers both. The answer settles the question it names, the run resumes, and where the conversation reaches a boundary that takes a user message the message is delivered as the turn after the answered one. Where it does not, the not-taken notice fires the same way it does for a message-only request.

Questions on the wire

On the AI SDK endpoint an approval arrives as two parts. A tool-approval-request mutates the tool part its toolCallId names, and the confirm gate runs before the run traces the call, so the format synthesizes that part first: a tool-input-available carrying the call’s id as toolCallId, the command path as toolName, and {"command": "<the rendered command line>", "tag": "<the tag that gated it>"} as its input. The approval part follows, naming the same toolCallId, with an approvalId of approval-<toolCallId> and the rendered command line as its reason. An answer names that toolCallId as its tool use id, which is where a console that does not use the vendored components gets it.

The confirm gate has three answers: refuse, run it once, and run it and stop asking about this tool. The AI SDK’s approval is a boolean and has no third state, so the AI SDK endpoint carries the three-way answer, and the three human-in-the-loop questions, in a fiskAnswer field on the POST body.

{
  "id": "thread-42",
  "messages": [],
  "fiskAnswer": {"toolUseId": "toolu_016s", "kind": "approve", "approval": "always"}
}
FieldDescription
toolUseIdthe call being answered, from the question that asked about it
kindapprove, confirm, select or input
approvalno, once or always, for approve
confirmedthe boolean answer, for confirm
indexthe position of the option chosen, for select
valuethe text answer, for input, where an empty string is a valid answer

A page that never sets fiskAnswer still works. An approval answered through the SDK’s own approval-responded part reaches the gate as run-it-once or refuse, and the questions that have no shape in the SDK arrive as data-question parts carrying toolUseId, kind, question, and options or default.

AG-UI needs none of this. Each question is an AG-UI interrupt on the run-finished event, carrying the question in words and a JSON Schema for the payload that answers it, and the client answers in the resume array of its next run.

{
  "threadId": "thread-42",
  "runId": "run-2",
  "messages": [],
  "resume": [
    {
      "interruptId": "approve:toolu_016s",
      "status": "resolved",
      "payload": {"approval": "always"}
    }
  ]
}

The interrupt id is the question’s kind and the call it belongs to, joined by a colon, and both ends derive it rather than remember it. A client that ignores the schema and sends the {"approved": true} AG-UI recommends gets run-once or refuse, the same two answers the AI SDK boolean gives. A cancelled entry settles nothing: the conversation resumes, the call is dispatched again, and the question is put again.

The AI SDK endpoint takes a field of this agent’s own for the answers its protocol cannot express, and the AG-UI endpoint carries all four questions in the protocol’s own vocabulary.

An AG-UI thread ends on the run that asked

An approval interrupt sends no assistant message, the gate running before the run traces the call, so the run that asked leaves nothing in the thread. A client whose accumulated thread ends on the person’s own message and then answers with no new message has that trailing message read as this turn’s prompt and runs it a second time. A client for this agent ends its thread on the run that asked.

Answering and typing on the AI SDK endpoint

An approval answered through the SDK’s own approval-responded part is read only when the newest message is the assistant’s. A stock frontend that answers that way and types in the same breath ends its history on the person’s message, so the request reaches the run as a prompt alone and the question is asked again. A page sending both in one request puts the answer in fiskAnswer, which is read whatever the history ends on.

The approval button does nothing on its own

The AI SDK’s automatic continuation after an approval does not fire against a server the library does not control. addToolApprovalResponse skips its automatic send while a response is streaming or submitted and never comes back to it, and lastAssistantMessageIsCompleteWithApprovalResponses separately wants every tool part in the message to be terminal. In stock AI Elements the approval control renders, the click lands, and nothing happens until the page POSTs again. The console has to send that POST itself.

An answer sent on its own goes with no new user message, as sendMessage(undefined, {body: {fiskAnswer}}). The worker takes an answer and a typed message on one body, and the client still renders that pair badly: a user message starts a fresh assistant message, so the tool-output-available part for the answered call finds no part to update, which the SDK raises as a stream error that ends the response early.

The response head names the assistant message the turn writes into. A request whose newest message is the assistant’s opens under that message’s id, so the answered question’s card is updated in place rather than a second copy of it appearing beside the first.

The agent card

GET /fisk/v1/card answers the agent’s self description as JSON, built per request, so a worker restarted with a different model or tool set answers with what it is running now.

FieldDescription
namethe routing identity
versionthe agent’s own version
descriptionwhat the agent is for, in the operator’s words
display_namethe human name, where name is the identity
icon, icon_urlan emoji and an https image URL, both decoration the agent asserts
notesone sentence per source whose tools are missing from this card
promptsthings a person can ask this agent, in the operator’s words
protocolsthe message namespaces this agent speaks
toolsthe tools, each with name, description, input_schema and behavior
modelthe model that answers a prompt
telemetry, telemetry_contentwhether the agent exports traces, and whether they carry the conversation

A tool’s behavior carries read_only, destructive, idempotent and open_world, each a tri-state where an absent field asserts nothing. Confirm-gated commands are on this card, where the a2a card drops them: this channel has an operator in front of it who can approve one.

icon_url is an unverified claim from the configuration, checked for an https scheme and a length and nothing more. Fetching it discloses every viewer to the host the card named, and proxying it creates a server-side request forgery, so whether to fetch it at all is the console’s decision.

Listing, opening and deleting a conversation

Picking a past conversation is outside what either frontend protocol has words for, so these routes are Fisk’s own and every format shares them.

GET /fisk/v1/sessions takes limit and cursor. The limit defaults to 20 and is refused outside 1 to 100. The cursor is the store’s own value: a console stores the one it was given and hands it back unchanged, and it stays good across a reload and a restart.

{
  "sessions": [
    {
      "id": "w-9f2c...",
      "title": "list the streams",
      "created": "2026-09-08T09:12:44Z",
      "updated": "2026-09-08T09:13:02Z",
      "model": "claude-sonnet-5",
      "terminal": "completed",
      "summary": {"turns": 3, "context_tokens": 18422, "tool_calls": 5}
    }
  ],
  "cursor": "MTI4"
}

Rows come oldest first, which is the order a write-once journal can enumerate. summary is absent for a conversation whose last turn ended before a turn recorded one, so a rail shows an empty slot rather than counts of zero. terminal is empty for a conversation with a turn in flight. cursor is absent once the listing has reached the end, and a page that fills to the limit always carries one, so a full last page is followed by an empty page carrying none.

GET /fisk/v1/sessions?id=w-9f2c...&id=w-41ab... answers the rows for the ids it names rather than a page. They come back in the order they were named, carry no cursor, and a row repeated in the request is answered once. A frontend holding its own map of people to conversations reads its rail this way: it knows the ids already and has nothing to enumerate. At most 50 ids, which keeps the URL under 4KB at 66 characters an id, and more than that is a 400. Naming id together with limit or cursor is a 400 as well.

An id this channel does not hold is left out of sessions, as is one whose journal is corrupt or written under a version this build does not read, both of which the paged listing also leaves out. So a caller naming twelve ids and reading eight rows knows the other four are not here.

GET /fisk/v1/{format}/sessions/{id} writes a stored conversation in the format it is mounted under: the user turns, the assistant turns, and the calls with the results that answered them. A conversation the run left waiting on a question ends on the question it stopped at, so opening it shows the approval card it was left on. The two formats write it in different shapes.

The AI SDK stream sends the parts a live turn sends, with each text and reasoning block written whole rather than in fragments. It is one assistant message and has no part for a person’s turn, so a replayed user turn goes as a data-user-message part whose data is {"text": "<what the person typed>"}. The client stores that part and no shipped component draws it, so a console renders the user turns of a reopened conversation itself.

AG-UI sends the whole conversation as one MESSAGES_SNAPSHOT event rather than the text-message and tool-call events a live run produces, so a client waiting for those renders nothing. The snapshot sits in an ordinary run frame: RUN_STARTED and the fisk.capabilities custom event, the snapshot, then RUN_FINISHED, carrying the interrupt when the conversation stopped on a question. The run id of that frame is the session id prefixed with open-, the request naming no run of its own.

DELETE /fisk/v1/sessions/{id} answers 204. An id this listing does not show is a 404 and is never removed.

Nobody is authenticated

Every request is anonymous. Neither format reads a caller name out of a request, the run records its caller as unverified, and nothing in the agent decides anything on it.

Anyone who reaches the session endpoints lists, opens and deletes any conversation this channel holds. The session id prefix keeps a browser out of a Slack thread’s journal and the agent identity keeps two agents on one JetStream stream apart. Neither keeps one person out of another person’s conversation.

That is adequate on loopback and nowhere else. A deployment past it puts an authenticating proxy in front of the listener, and the caller then comes from a header that proxy sets, which is configuration this channel does not have today.

While the listener is bound to a loopback address it refuses any request whose Host is not 127.0.0.1, ::1 or localhost on the bound port, which stops a page on the internet reaching a loopback listener through DNS rebinding. The port is part of that check and a Host carrying none is read as port 80, so a development proxy that forwards a browser’s Host of localhost:5173 to a listener on 127.0.0.1:8080 is refused 403. Such a proxy sets the Host to the listener’s own address and port.

On any other listen address the check is skipped: the proxy in front owns the public name, and Caddy and Traefik pass the browser’s Host through unchanged.

Escaping is the console’s job

Nothing here escapes anything for a browser. The gate’s rendered command line, tool arguments, tool output, the assistant’s own text and the reasoning beside it are all model-supplied or tool-supplied, and they reach the response as they were produced. AG-UI’s fisk.warning custom events carry a tool name and an error string from the same sources. A console escapes all of it before it reaches the page.

One turn at a time on a conversation

Two POSTs on one thread do not both run. The first append of a resume is the journal’s claim record, written before any model call, so the loser fails with nothing streamed and the channel answers it 409. A console sends the next turn after the last response ended.

One worker runs a fixed number of turns at once across all its threads, five today, from web.DefaultWorkers with no configuration key to change it. A request arriving above that is refused 503 rather than taken and left holding an open response with no head, and it carries Retry-After from the channel’s own retryAfter constant, five seconds today. A console reads the header rather than the number here: both values are constants the tests compare against, so either can change without this page being updated.

Running several workers

Several fisk serve processes may sit behind a load balancer, and the channel holds nothing between requests for that reason: a thread is a session in the store, and a held answer lives for the one turn that carries it.

Those workers need one shared journal. The file session backend is a directory on one machine, so two workers reading their own would answer the same thread from different conversations and show different session lists. A deployment of more than one worker sets harness.sessions.backend: jetstream, covered in Sessions.

Origins

origins has no default and no wildcard, and a console is unreachable until an operator names where it is served from. Each entry is what a browser sends in the Origin header, scheme, host and optional port with no path.

The channel enforces the list rather than leaving it to the browser. CORS decides who may read an answer, while a cross-origin POST still reaches the handler and would still run a turn, so a request carrying an unlisted Origin is refused 403 before its body is read. A request carrying no Origin is not a browser’s cross-origin request and passes.

A preflight from a listed origin is answered 204 advertising GET, POST, DELETE, OPTIONS, and an allowed response carries Access-Control-Expose-Headers: * so a page can read a format’s own headers.

Prompts channel

The prompts channel takes a prompt from another agent over NATS and runs the agent loop over it. A caller publishes a request on the worker’s own subject and receives an acknowledgement, then the events the run produces, then the answer or the failure.

Warning

A request carries no verified caller identity. Publish permission on the request subject is the whole of the access control, covered in Answering prompts.

Turning it on

identity: nats-worker
application_path: /usr/local/bin/nats
nats_context: production
system_prompt: |
  You operate NATS.
llm:
  model: claude-sonnet-5
expose:
  agent:
    a2a:
      prompts:
        workers: 2

The presence of expose.agent.a2a.prompts is the switch. identity, system_prompt, llm.model and nats_context are required beside it, since answering a prompt runs the whole agent loop. Every key the block takes is in Answering prompts.

Making requests

A caller publishes a request on choria.fisk-ai.task.<identity>:

{
  "protocol": "io.choria.fisk-ai.v1.request.prompt",
  "id": "3Hzmp2RBLG713TfOTU5aJpATRg2",
  "request": "docs1",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T11:00:00Z",
  "sender": {"name": "peer1"},
  "prompt": "how many streams are there"
}

request names the turn. Every reply to it echoes the value, canceling the turn addresses it by that value, and so does answering a question the run asks, so pick it before you send and keep it. It must name one turn and one only: two turns sharing a value make their replies indistinguishable, and a cancel aimed at one of them stops both. It is at most 64 characters of letters, digits, - and _, because a worker builds subjects from it.

id names the message rather than the turn, so it is fresh on every message including a resend, and conversation is the caller’s own tag across the turns of one conversation.

ProtocolAsks forRequiredAlso takes
io.choria.fisk-ai.v1.request.prompta turn: the agent runs the promptpromptcontext, tool_hints, budget, stream, conversation_token, replay, force
io.choria.fisk-ai.v1.request.answera question answered and the run resumedconversation_token, answerbudget, stream, replay, force
io.choria.fisk-ai.v1.request.resumea run that stopped part way continuedconversation_tokenbudget, stream, force
io.choria.fisk-ai.v1.request.readthe conversation read back, no turn takenconversation_token, replaynothing

The queued-jobs channel takes a request.prompt as its payload and none of the other three.

request.answer is described in Answering after the run ended, and request.read in Reading a conversation.

context is supporting material offered alongside the prompt, stream: false asks for the answer without the event stream, and conversation_token joins an existing conversation, see Follow-up turns.

force is a caller’s decision about its own conversation. Without it a worker refuses a resume across a changed model, system prompt or tool set; with it the run continues under the current configuration and drops the standing approvals it can no longer vouch for.

A budget above the worker’s own configuration is ignored. On a conversation it limits the conversation rather than the turn, since a run measures the whole journal’s token count against it.

The reply set arrives on the request’s own inbox, in order:

MessageWhen
ackonce, first, saying whether the prompt was taken
event.<kind>zero or more, carrying the run’s output as it is produced
elicit.request.<kind>a question the run puts to the caller, only when elicit is set
resultthe answer, with its stop reason and token usage
errorinstead of a result when the run did not produce one

The acknowledgement comes first, so a plain nats req receives it and stops there:

$ nats req choria.fisk-ai.task.nats-worker "$(cat request.json)"
{"protocol":"io.choria.fisk-ai.v1.ack","id":"3Hzmp3SCMH824UgPUV6bKqBUSh3","request":"docs1",
 "conversation":"docs1","sequence":1,"time":"2026-08-16T11:24:10.749134Z",
 "sender":{"name":"nats-worker"},"recipient":{"name":"peer1"},"accepted":true}

Every message of the set carries sequence, numbered from the acknowledgement without gaps, so a caller can tell a lost event from a quiet run. Events are advisory. The answer is in the terminal message, and the worker’s run journal is the authoritative transcript.

Event blocks

Each event holds one block. Where an id under io.choria.fisk-ai.v1.event. is one your client does not recognize, keep the message and render what you can rather than rejecting it: a newer worker sends kinds this one does not define.

ProtocolFieldsWhat it is
io.choria.fisk-ai.v1.event.texttext, finalthe model’s prose
io.choria.fisk-ai.v1.event.thinkingtextthe model’s reasoning, when it produces any
io.choria.fisk-ai.v1.event.tool_callid, name, inputa tool the run is about to invoke
io.choria.fisk-ai.v1.event.tool_resultcall_id, output, is_errorwhat that call returned
io.choria.fisk-ai.v1.event.agent_callid, name, taska question delegated to a peer agent
io.choria.fisk-ai.v1.event.warningkind, name, count, params, erroran advisory the run raised
io.choria.fisk-ai.v1.event.prompttexta turn somebody asked for; sent only in a replay
io.choria.fisk-ai.v1.event.statusiteration, usage, phase, count, truncatedprogress, and the markers around a replay

A text event in full:

{"protocol":"io.choria.fisk-ai.v1.event.text","id":"3Hzmp8kRt1BqA4dQ2v9XnLcYm2T","request":"docs1",
 "conversation":"docs1","sequence":2,"time":"2026-08-16T11:24:11.104217Z",
 "sender":{"name":"nats-worker"},"block":{"text":"the stream is gone","final":true}}

final marks the answer. Only the run knows which message ended the turn, so without the flag a caller cannot tell the answer from the narration on the way to it, and would render it twice when the same text arrives again in the result.

A warning names its kind and gives you the values, not a finished sentence. Your client chooses the wording, and a client that does not recognize a kind can still display the fields.

A tool_call is not answered twice. A call the caller was asked to approve carries the same tool_use_id as the elicit.request.approve that asked, so a caller that drew the question knows it has already shown that call.

Not every call produces a result: a denied confirmation, a tool called without its required arguments, a tool that answers later and an aborted run each end without one, so a caller pairing the two tolerates a call that is never answered.

A status block reports progress, and its usage is what one model call consumed. The call that ends a turn sends no status of its own, so a caller keeping a running total takes the totals from the terminal message rather than summing these. The replay markers use the same block, in Reading a conversation.

Refusals and endings

The worker refuses a request it cannot parse with a NATS service error, before any acknowledgement:

$ nats req choria.fisk-ai.task.nats-worker '{"protocol":"io.choria.fisk-ai.v1.request.resume","prompt":"...", ...}'
Nats-Service-Error: the request is not a valid v1 message: jsonschema validation failed with
'https://choria.io/schemas/io.choria.fisk-ai.v1/request.resume.json#'
  - at '/prompt': false schema
Nats-Service-Error-Code: 400

A resume takes no prompt. Send io.choria.fisk-ai.v1.request.prompt to run one.

Everything the worker refuses after that is an ack with accepted: false and a reason, followed by an error that closes the set. The error carries a code the caller can branch on:

CodeMeaning
rejectedadmission refused the caller; the message says why
capacityevery worker slot is busy; retry, or ask another instance
duplicate_requesta run with this request id is already in flight here
drainingthe worker is shutting down and never started this run
not_startedthe prompt was taken and the worker stopped before running it
failedthe run ran and failed; the message says how
crasheda bug in this software; the detail stays in the worker’s log
canceledthe run was stopped before it finished, by the worker rather than by the caller
suspendedthe run stopped at a resumable point, which is what a caller’s own cancel reaches
deferreda tool will answer later, so the run is parked
unknown_conversationthe conversation_token names no conversation here; send the prompt without one
conversation_busya turn of this conversation is running here; wait for its terminal message
turn_not_takenthe conversation could not take the turn, and the prompt did not run
config_driftthe agent’s configuration changed under the stored conversation, so the resume was refused; the message lists what changed
budget_exhaustedthe conversation has used its whole token allowance and is finished
provider_busythe agent’s model provider had no capacity or refused a rate-limited call; wait and send the same work again
provider_refusedthe agent cannot use its model provider at all; an operator has to fix its credentials or its model name
context_exceededthe conversation holds more than the model’s context window takes, so the model refused the call; start a new conversation or send less context
unknown_callno such call is waiting for an answer
already_answeredthe call already has an answer
answer_too_largethe answer is over 256KB

The worker refuses at capacity rather than queueing the prompt. unknown_call, already_answered and answer_too_large are permanent: sending the same answer again reaches the same reply.

budget_exhausted ends the conversation, not just this request. The allowance belongs to the conversation, so every later turn is refused no matter who sends it. Your prompt did not run and was not recorded. To carry on, send a prompt with no conversation_token to start a new conversation. Only an operator on the machine running the agent can raise llm.budget.max_tokens.

You can also hit this straight away, on a conversation that answered a moment earlier, by lowering budget on your own request below what the conversation has already used.

Every error also has a stop_reason beside its code. budget_exhausted appears there when a run hit the cap part way through a turn instead of before it started.

A deferred run is waiting for a tool answer. The error lists the calls. Answer one on a request carrying the conversation token, or with fisk session on the worker holding the journal.

Canceling

A caller cancels by publishing an io.choria.fisk-ai.v1.cancel on choria.fisk-ai.cancel.<identity>.<request>, where request is the correlation id it sent. The request id is part of the subject, so only the worker running that prompt is subscribed to it. It answers with an ack.

A cancel asks the run to stop where the conversation can be continued. It does not end the run where it stands: the loop polls for it at each boundary and parks there, so the terminal message is suspended with the usage the turn spent, and the conversation takes another turn whenever the caller sends one. A run blocked on a question is included, since a cancel closes the question rather than leaving the run blocked on it.

That costs the ability to stop a model call in flight. A run inside a tool that never returns reaches no boundary, and a cancel will not move it; that escape hatch belongs to whoever operates the worker.

Because the id is part of the subject, a caller mints it rather than being told it: set id and request on the request before sending, so the tag is in hand before there is anything to cancel.

A no-responder error means this instance is not running that request: it was never accepted, it already finished, or another instance took it.

Follow-up turns

A caller can send another turn of the same conversation. Every ack that accepts a prompt carries a conversation_token, and a later request carrying that token runs its prompt as the conversation’s next turn:

{"protocol":"io.choria.fisk-ai.v1.ack","id":"3Hzmp3SCMH824UgPUV6bKqBUSh3","request":"docs1",
 "conversation":"docs1","sequence":1,"time":"2026-08-16T11:24:10.749134Z",
 "sender":{"name":"nats-worker"},"recipient":{"name":"peer1"},"accepted":true,
 "conversation_token":"3Hzmp8VqrKL42NmXcPd7bTgWfR1"}
{
  "protocol": "io.choria.fisk-ai.v1.request.prompt",
  "id": "3Hzmq7WdPK628XjRVZ8cLmBUTh4",
  "request": "docs2",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T11:26:00Z",
  "sender": {"name": "peer1"},
  "prompt": "what is the first one called",
  "conversation_token": "3Hzmp8VqrKL42NmXcPd7bTgWfR1"
}

A caller that asks once and stops ignores the token the worker handed it; one that wants another turn sends the token it already has. A follow-up opens a reply set of its own, with its own ack, events, cancel address and terminal message, so it is an ordinary request in every respect but which conversation it joins.

No worker holds a conversation between turns. Each turn loads the journal, runs, and stores the result, so any instance in the queue group serves any turn. That also means a caller sends one turn at a time: a second turn sent while the first is still running is refused with conversation_busy, and it must wait for the first turn’s terminal message rather than try another instance.

A conversation has no end state and no expiry: the journal stays in the session store until an operator removes it.

  • A turn cannot join a conversation waiting on a deferred tool result. The worker answers turn_not_taken without running the prompt. With elicit set, a human-in-the-loop question the caller neither answers nor holds open within request_timeout leaves the conversation waiting on a deferred call. Answer the question and it takes turns again.
  • A configuration change ends a conversation. Every turn is a resume, and the worker refuses a resume when the model, the system prompt, the thinking mode or the reasoning effort has changed since the conversation started. It answers config_drift with a message naming what changed, and the caller either starts a new conversation or sends the same request again with force. A changed tool set does not end it: the turn runs, and the standing approvals the conversation held are dropped, since an approval names a tool and that tool may have moved under it.

The usage on a result counts the whole conversation rather than the turn, since it is read from the journal. An error that ran and stopped carries it too, so a caller can tell what it owes for a turn it is about to continue. Both also carry trace_id, the trace the worker recorded, which is empty when it exports no telemetry, and content_exported, which says whether this turn’s conversation itself reached that collector.

Asking what an agent is

Ask an agent what it is before you send it anything. Run fisk discover <identity> to make that request. Every agent that answers prompts also answers discovery, whether or not it serves tools as well. An agent that serves no tools to peers answers with a card that lists none.

The card describes what the agent does with a conversation:

FieldMeaning
telemetrythe agent exports traces of what it does
telemetry_contentthose traces carry the conversation itself, so a prompt sent here reaches the operator’s collector

They are published because a caller should know before it sends a prompt, and they are read off the worker’s resolved telemetry provider rather than its configuration, so a rejected endpoint does not leave the card promising an export that will not happen. The card says what the agent is configured to do; content_exported on a terminal message says what a turn actually did.

Reading a conversation

To read a conversation back, send an io.choria.fisk-ai.v1.request.read with a conversation_token and a replay count. The worker sends that many blocks of the stored conversation and ends the reply set:

{
  "protocol": "io.choria.fisk-ai.v1.request.read",
  "id": "3Hzmr9YfRM839ZlTXb0eNoDVUj6",
  "request": "docs3",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T11:30:00Z",
  "sender": {"name": "peer1"},
  "conversation_token": "3Hzmp8VqrKL42NmXcPd7bTgWfR1",
  "replay": 200
}

Use this to show a conversation your client did not see live, such as one started on another machine. A finished turn leaves a completed journal, and a plain resume will not continue one, so reading it is the only request such a conversation accepts until you send the next prompt.

You get back the same blocks the run sent the first time, between two status blocks:

  • phase: "replay_start" opens the history.
  • phase: "replay_end" closes it, with count blocks sent, truncated when older ones were left behind, and usage for what the conversation has consumed so far. That usage is the whole conversation rather than one call, so a caller can seed a running total before this turn’s own calls arrive.

Set replay on each request that needs it. Leave it off a follow-up turn, which usually wants only the new blocks. The worker caps the count at 200 however much you ask for, then rounds outwards to a whole turn so that a result never arrives without the call it answers, so a reply set can carry more blocks than the count it was given. The largest useful value is therefore 200; ask for more and you get 200. A read asks for at least 1.

Some of what the journal holds never leaves the worker: thinking signatures, the fingerprint, the caller, the conversation token, the standing approvals, and the notes and handles of deferred calls. Long values are trimmed to fit a block.

io.choria.fisk-ai.v1.request.resume continues a run that stopped part way, and a caller sends it after a suspended ending. Send it with the token and no replay.

Answering questions

A run puts a question back to the caller when it needs a person: an approval for a confirmation-gated command, or one of the three human-in-the-loop questions. A run asks only when the worker is configured with elicit, covered in Answering prompts.

The worker sends the question on the reply set, after the ack and before the result or error:

{
  "protocol": "io.choria.fisk-ai.v1.elicit.request.approve",
  "id": "3Hzq6PkmVLsT9WqrChXkgF7NLwy",
  "request": "docs1",
  "conversation": "docs1",
  "sequence": 4,
  "time": "2026-08-16T11:24:11.912084Z",
  "sender": {"name": "nats-worker"},
  "recipient": {"name": "peer1"},
  "question_id": "3Hzq7RvnWMtU0XstDiYlhG8OMxz",
  "tool_use_id": "toolu_01A9bK2mNpQr",
  "command": "stream rm",
  "display": "stream rm ORDERS --force",
  "tag": "ai:confirm",
  "wait_ms": 120000
}

Every question has question_id, and may have tool_use_id and wait_ms:

ProtocolAsksFields
io.choria.fisk-ai.v1.elicit.request.approvewhether a gated command may runcommand, display, and usually tag
io.choria.fisk-ai.v1.elicit.request.confirma yes or no questionquestion
io.choria.fisk-ai.v1.elicit.request.selectone of a listquestion, options
io.choria.fisk-ai.v1.elicit.request.inputa free text valuequestion, and default when it pre-fills

tag is absent when the gate could name no trigger, which is a command rewritten to a tool that has none.

The caller answers on choria.fisk-ai.elicit.<identity>.<request>, where request is the correlation id it sent:

$ nats req choria.fisk-ai.elicit.nats-worker.docs1 "$(cat answer.json)"
{
  "protocol": "io.choria.fisk-ai.v1.elicit.reply.approve",
  "id": "3Hzq8TwoXNuV1YtuEjZmiH9PNya",
  "request": "docs1",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T11:24:19.310422Z",
  "sender": {"name": "peer1"},
  "question_id": "3Hzq7RvnWMtU0XstDiYlhG8OMxz",
  "choice": "once"
}

Reply under the id you were asked under, with request swapped for reply:

ProtocolFieldValues
io.choria.fisk-ai.v1.elicit.reply.approvechoiceno, once, always
io.choria.fisk-ai.v1.elicit.reply.confirmconfirmedtrue, false
io.choria.fisk-ai.v1.elicit.reply.selectindexa position in options
io.choria.fisk-ai.v1.elicit.reply.inputvalueany string, empty included
io.choria.fisk-ai.v1.elicit.reply.no_operatornoneno operator is available

Send the field even when its value is the zero one. confirmed: false, index: 0 and value: "" are each an answer somebody gave.

io.choria.fisk-ai.v1.elicit.waiting arrives on the same subject and is not an answer. It says the caller is holding the question open, see Holding a question open.

The worker replies with an ack. An answer to a question it is not waiting on gets a 404, as does an answer sent after the question’s window closed, and as does a waiting sent after the question was answered.

once runs the command that one time. always stops the worker asking about that tool for the rest of the conversation. no and no_operator both leave the command unrun and tell the model the refusal is final.

The worker holds the question for expose.agent.a2a.request_timeout, and its worker slot with it. The question’s wait_ms carries that number, so the caller knows how long it has. An unanswered question ends the run differently depending on what asked it:

  • an approval leaves the command unrun, and the run ends with suspended
  • a human-in-the-loop tool leaves its call deferred, and the run ends with deferred

Both can be answered later, see Answering after the run ended. An operator on the worker holding the journal answers a deferred call with fisk session instead.

Holding a question open

A person reading a command approval can take longer than two minutes. A caller with the question in front of somebody sends an io.choria.fisk-ai.v1.elicit.waiting, and each one restarts the window:

{
  "protocol": "io.choria.fisk-ai.v1.elicit.waiting",
  "id": "3Hzq9UxpYOvW2ZuvFk0njI0QOzb",
  "request": "docs1",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T11:25:59.104812Z",
  "sender": {"name": "peer1"},
  "question_id": "3Hzq7RvnWMtU0XstDiYlhG8OMxz"
}

The rules a client follows:

  • Send a waiting every wait_ms / 3, starting when the question goes on screen. The window restarts when the worker receives the message, so the remaining two thirds cover the round trip and one lost message. In Go, wire.NewWaitingAck(question, sender) builds the message and question.AckInterval() is the interval.
  • Stop before sending the answer. A waiting that arrives after the answer is refused, since the worker has finished with the question.
  • A 404 means the question is gone: take it off the screen and send no answer, since that would be refused too.
  • A 400, or a question with no wait_ms, comes from a worker older than this feature. Answer inside the window instead.
  • Send no_operator when the person walks away. waiting says somebody is there to answer, and no_operator ends the question at once. Silence leaves the command unrun too, but only after a whole window.
  • The reply set is silent while the worker holds the question, so a client learns the worker is still there only from the ack to each waiting.

A caller that sends no waiting either answers within the window or answers later, on a request of its own.

Answering after the run ended

A person closes a laptop with a question on screen. The waiting messages stop, the window runs out, and the run ends suspended or deferred. The worker unsubscribes from choria.fisk-ai.elicit.<identity>.<request> with the task, so an hour later their answer reaches no responder.

They send an io.choria.fisk-ai.v1.request.answer instead, with the conversation token:

{
  "protocol": "io.choria.fisk-ai.v1.request.answer",
  "id": "3Hzms0ZgSN940AmUYc1fOpEWVk7",
  "request": "docs4",
  "conversation": "docs1",
  "sequence": 0,
  "time": "2026-08-16T12:40:03Z",
  "sender": {"name": "peer1"},
  "conversation_token": "3Hzmp8VqrKL42NmXcPd7bTgWfR1",
  "answer": {
    "tool_use_id": "toolu_01A9bK2mNpQr",
    "kind": "approve",
    "answer": "choice",
    "choice": "once"
  }
}

Copy tool_use_id and kind from the question. A resumed run mints a new question_id, so the answer names the call instead.

The answer object has kind and answer of its own. answer names the field holding the decision, and kind says what that decision means where the value alone cannot: no_operator looks the same whichever question was asked, and value serves both input and select.

FieldValue
tool_use_idthe call the question named
kindapprove, confirm, select or input
answerchoice for approve, confirmed for confirm, value for select and input, or no_operator
choiceno, once or always
confirmedtrue or false
valuethe text for input, and the chosen option for select

A selection names the option, not its position.

You get back the usual ack, events, and a result or an error. The conversation gains no turn. A deferred call takes the answer as its result; an approval is asked again by the resume and answered from the request.

A 400 means the answer does not fit its kind, or the message has no token, or it came with a prompt.