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.