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
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.
| Field | Description |
|---|---|
toolUseId | 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.