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
Web channel: Drive an agent from a browser over HTTP
Prompts channel: Send an agent a prompt over NATS and read the run back
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.
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.
Method
Path
Answers
POST
/fisk/v1/vercel
one 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/agui
one turn as AG-UI events
GET
/fisk/v1/agui/sessions/{id}
a stored conversation as AG-UI events
GET
/fisk/v1/card
the agent card as JSON
GET
/fisk/v1/sessions
stored 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.
Code
When
200
a turn, a stored conversation, the card or a listing
204
a deleted conversation, and a CORS preflight
400
a 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
403
an Origin outside the configured list, or a Host that does not name the loopback listener on its bound port
404
an answer for a thread holding no conversation, a session this channel does not hold, an unmounted path
405
a method the route does not answer
409
a second turn on a conversation already running
500
a card that cannot be built, or stored conversations that cannot be listed or read
503
a 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.
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.
the call being answered, from the question that asked about it
kind
approve, confirm, select or input
approval
no, once or always, for approve
confirmed
the boolean answer, for confirm
index
the position of the option chosen, for select
value
the 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.
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.
Field
Description
name
the routing identity
version
the agent’s own version
description
what the agent is for, in the operator’s words
display_name
the human name, where name is the identity
icon, icon_url
an emoji and an https image URL, both decoration the agent asserts
notes
one sentence per source whose tools are missing from this card
prompts
things a person can ask this agent, in the operator’s words
protocols
the message namespaces this agent speaks
tools
the tools, each with name, description, input_schema and behavior
model
the model that answers a prompt
telemetry, telemetry_content
whether 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.
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.
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>:
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.
Protocol
Asks for
Required
Also takes
io.choria.fisk-ai.v1.request.prompt
a turn: the agent runs the prompt
prompt
context, tool_hints, budget, stream, conversation_token, replay, force
io.choria.fisk-ai.v1.request.answer
a question answered and the run resumed
conversation_token, answer
budget, stream, replay, force
io.choria.fisk-ai.v1.request.resume
a run that stopped part way continued
conversation_token
budget, stream, force
io.choria.fisk-ai.v1.request.read
the conversation read back, no turn taken
conversation_token, replay
nothing
The queued-jobs channel takes a request.prompt as its payload and none of the other three.
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:
Message
When
ack
once, 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
result
the answer, with its stop reason and token usage
error
instead of a result when the run did not produce one
The acknowledgement comes first, so a plain nats req receives it and stops there:
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.
Protocol
Fields
What it is
io.choria.fisk-ai.v1.event.text
text, final
the model’s prose
io.choria.fisk-ai.v1.event.thinking
text
the model’s reasoning, when it produces any
io.choria.fisk-ai.v1.event.tool_call
id, name, input
a tool the run is about to invoke
io.choria.fisk-ai.v1.event.tool_result
call_id, output, is_error
what that call returned
io.choria.fisk-ai.v1.event.agent_call
id, name, task
a question delegated to a peer agent
io.choria.fisk-ai.v1.event.warning
kind, name, count, params, error
an advisory the run raised
io.choria.fisk-ai.v1.event.prompt
text
a turn somebody asked for; sent only in a replay
io.choria.fisk-ai.v1.event.status
iteration, usage, phase, count, truncated
progress, 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-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:
Code
Meaning
rejected
admission refused the caller; the message says why
capacity
every worker slot is busy; retry, or ask another instance
duplicate_request
a run with this request id is already in flight here
draining
the worker is shutting down and never started this run
not_started
the prompt was taken and the worker stopped before running it
failed
the run ran and failed; the message says how
crashed
a bug in this software; the detail stays in the worker’s log
canceled
the run was stopped before it finished, by the worker rather than by the caller
suspended
the run stopped at a resumable point, which is what a caller’s own cancel reaches
deferred
a tool will answer later, so the run is parked
unknown_conversation
the conversation_token names no conversation here; send the prompt without one
conversation_busy
a turn of this conversation is running here; wait for its terminal message
turn_not_taken
the conversation could not take the turn, and the prompt did not run
config_drift
the agent’s configuration changed under the stored conversation, so the resume was refused; the message lists what changed
budget_exhausted
the conversation has used its whole token allowance and is finished
provider_busy
the agent’s model provider had no capacity or refused a rate-limited call; wait and send the same work again
provider_refused
the agent cannot use its model provider at all; an operator has to fix its credentials or its model name
context_exceeded
the 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_call
no such call is waiting for an answer
already_answered
the call already has an answer
answer_too_large
the 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.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:
Field
Meaning
telemetry
the agent exports traces of what it does
telemetry_content
those 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:
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:
Reply under the id you were asked under, with request swapped for reply:
Protocol
Field
Values
io.choria.fisk-ai.v1.elicit.reply.approve
choice
no, once, always
io.choria.fisk-ai.v1.elicit.reply.confirm
confirmed
true, false
io.choria.fisk-ai.v1.elicit.reply.select
index
a position in options
io.choria.fisk-ai.v1.elicit.reply.input
value
any string, empty included
io.choria.fisk-ai.v1.elicit.reply.no_operator
none
no 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:
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:
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.
Field
Value
tool_use_id
the call the question named
kind
approve, confirm, select or input
answer
choice for approve, confirmed for confirm, value for select and input, or no_operator
choice
no, once or always
confirmed
true or false
value
the 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.