Channels

A channel supplies work to an agent and returns the answer. A work queue, a NATS request subject and an HTTP listener are channels today; a caller in the same process would be a channel too.

The fisk serve command hosts an agent behind the channels. The queued-jobs channel polls a work queue. The prompts channel answers a request on a NATS subject. The Slack channel answers people who mention a bot in a thread. The web channel answers a browser over HTTP. The agent loop is the same in each case and does not see the difference.

fisk serve also hosts endpoints that produce no work. Serving tools answers another agent’s tool call directly, running one tool. It starts no agent loop, so the behavior on this page does not apply to it.

Note

Queued jobs, prompts from other agents, Slack and the web listener are the channels that ship today.

Channels and fisk serve are available since Version0.0.5.

Channel capabilities

Channels differ in what they can offer a run:

CapabilityDescription
Streamingwhether a caller sees output as it is produced
Elicitationwhether a run can ask a person a question mid-run
Follow-up turnswhether a conversation continues after the first answer
Caller identitywhat the channel reports about the caller

What each shipped channel offers:

ChannelStreamingElicitationFollow-up turnsCaller identity
Queued jobsnononounverified sender field
a2a promptsyesoptionalyesunverified sender field
Slacknoyesyesthe Slack user who spoke
Webyesyesyesnobody is authenticated

No caller waits for a queued job, so that channel does not stream output and does not take a second turn.

A web turn ends on any question it asks, since the page that would answer is reading the response. The answer arrives on the page’s next request.

The prompts channel sends output to the caller as the worker produces it. It returns a conversation token with every prompt it accepts. Send that token on a later request to continue the conversation.

To let a run ask the caller a question, set expose.agent.a2a.prompts.elicit. Leave it unset and the agent asks nobody.

Confirmation-gated tools

An agent with nobody to ask still offers every tool to the model, including the confirmation-gated ones. The model can call one. The confirm gate then refuses the call and tells the model why.

Where to go next

  • Serving: the serve command and the settings every channel shares
  • Queued jobs: take whole units of work from a Choria work queue
  • Slack: answer people in a Slack workspace, one thread being one conversation
  • Web: answer a browser over HTTP, one thread being one conversation
  • Serving tools: serve this agent's tools to other agents over NATS
  • Answering prompts: take prompts from other agents and stream the run back to them

Subsections of Channels

Serving

The fisk serve command hosts an agent behind the endpoints its configuration enables. It runs until interrupted. The agent is the one fisk run drives: tool set, prompt, model and harness settings come from the same configuration file.

Queued jobs takes each job off a work queue, runs it, and stores the answer for the submitter to read later. Answering prompts takes a prompt from another agent and streams the run back while that agent waits. Serving tools runs one tool for another agent and starts no agent loop.

Note

At least one endpoint must be enabled. fisk serve exits with an error when the configuration enables none.

Starting a worker

A minimal configuration has the application path, the tool selection and one channel. Here that channel is queued jobs:

identity: worker
application_path: /usr/local/bin/nats
nats_context: production
system_prompt: |
  You inspect NATS servers on behalf of an operator. Answer concisely.
include:
  tools:
    - ^stream_
expose:
  agent:
    jobs: {}
$ fisk serve --config nats.yaml

The startup banner lists the endpoints it started and the settings every run uses:

Serving worker/1.2.0:

         Endpoints: asyncjobs/FISK_AI
             Model: claude-sonnet-5
     Queue Context: production
          Sessions: jetstream (FISK_SESSIONS)
         Knowledge: disabled
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s
     Queue Workers: 1 (config)

The banner adds an Agent Context line when the agent’s nats_context differs from the queue’s.

Each endpoint prints its own section below. It shows the addresses that endpoint answers on and the limits it uses. Answering prompts and serving tools show examples.

Shared resources

fisk serve builds the model provider, the session store, the memory store, the knowledge index and the NATS connection once at startup. Every run shares them. A missing stream or bucket fails the process immediately instead of failing whatever job happens to arrive first.

Warning

A worker whose storage does not exist fails at startup. Under a supervisor that restarts on failure it crash-loops.

When the knowledge index does not exist at startup, each run opens the index for itself, so an index built after the worker started is visible to later runs.

Concurrency

Each channel limits its own runs, so a process serving two channels at two runs each is running four.

--workers sets how many queued jobs run at once, overriding expose.agent.jobs.workers:

$ fisk serve --config nats.yaml --workers 4

--workers affects the queued-jobs channel only. The prompts channel takes its count from expose.agent.a2a.prompts.workers and refuses a caller when every slot is busy.

A work queue has a concurrency setting of its own that limits every worker on it together. Setting workers above what the queue allows leaves slots idle rather than raising throughput.

Timeouts

harness.tool_timeout limits a single tool call, in fisk serve and fisk run alike. The default is five minutes. 0s removes the limit, for commands that run for hours.

Note

--workers overrides the configuration file. harness.tool_timeout in the file overrides the built-in default.

The timeout stops a command and its process group. It does not stop an in-process handler that ignores its context.

Where tools run

Command tools run in the worker’s own working directory unless --work-dir names another. It must be an absolute path that already exists.

Every run shares it. Set the worker count to 1 when a tool writes local state that concurrent runs would corrupt.

Note

A CLI that reads a context or a profile of its own does not inherit the agent’s nats_context, so pass the selection explicitly where it matters.

Shutdown

On the first interrupt the worker drains. It takes no new work, and runs in flight continue to their next resumable point:

draining: no new work is taken and running work stops where it can resume. Interrupt again to stop now

A second interrupt stops the worker at once. The queue redelivers any queued job still running, and the redelivery resumes from the journal. The prompts channel answers its callers with a failure instead.

A drain stops every endpoint, so a worker also serving tools stops answering peers at the same point. A worker with no channel has nothing to resume:

draining: the endpoints stop answering. Interrupt again to stop now

Sessions

Each channel journals its runs, so an interrupted run resumes instead of making the same model calls a second time.

Sessions need a store every worker can read. On one machine the default file backend is enough. Across machines, configure a shared harness.sessions backend. Without one, a job redelivered to a different worker cannot read the journal and starts again.

When two workers reach the same journal, the second one claims it. The claim is written before the run starts, and the first worker sees it before its next tool call and stops. Only a tool already running can execute twice.

Settings a channel run ignores

The following settings narrow the MCP and a2a tool endpoints, not a run served over a channel:

  • expose.agent.tools selects what is served over MCP and a2a. A channel runs the whole agent loop, so it uses the agent’s own include and exclude instead
  • the waiver that lets a tool-serving configuration omit identity, system_prompt and llm.model does not apply to a channel, since a run needs all three

Safety

A served run is a full agent loop driven by caller-supplied prompt text, running every tool the configuration allows. The channel’s own admission check is therefore the only access control: queue publish permission for queued jobs, NATS publish permission for prompts.

harness.tool_timeout limits each tool call and llm.budget limits the run. A caller may lower the budget, never raise it. The tool safety rules described in the Reference hold here as everywhere else: commands run as an argument vector rather than through a shell, each argument is checked against the command’s schema, and credentials are stripped from tool environments.

Queued jobs

The queued-jobs channel takes whole units of work off a Choria asyncjobs work queue, runs the agent loop against each one, and stores the answer back on the task. The submitter holds no connection to the worker: it enqueues a task, and reads the answer off the task record once a worker has written it.

Note

The channel is opt-in. The configuration must carry an expose.agent.jobs block, otherwise fisk serve has no queue to bind to.

The queued-jobs channel is available since Version0.0.5.

Creating the storage

A worker requires its storage to exist. Create it with ajc, version 0.4.0 or newer.

The task store holds every job and the answer written back to it:

$ ajc tasks initialize

The work queue holds the jobs waiting to be taken:

$ ajc queue add FISK_AI --run-time 10m --tries 3 --concurrent 10

Run time, retry cap and concurrency are properties of the queue, not of the agent configuration. The worker reads them from the consumer at startup and prints them on the banner. The run time must be longer than a job takes, or the queue redelivers work that is still running.

A worker started before either exists fails:

fisk: error: building the jobs endpoint: connecting to queue "FISK_AI": storage not ready: stream CHORIA_AJ_TASKS does not exist, create it with 'ajc tasks initialize'

Submitting work

A caller enqueues a task with the queue engine’s own client. The task must name the configured queue and task type, and its payload is a v1 prompt request:

ItemValue
Queueexpose.agent.jobs.queue, default FISK_AI
Task typeexpose.agent.jobs.task_type, default fisk-ai:run
Payloadan io.choria.fisk-ai.v1.request.prompt message

A queue has nobody waiting on it, so the three other kinds of request are refused here: they act on a conversation somebody is watching.

The request holds the prompt and the framing every v1 message needs:

{
  "protocol": "io.choria.fisk-ai.v1.request.prompt",
  "id": "2fT8kMxQ1aBcDeFgHiJkLmNoPqR",
  "request": "2fT8kMxQ1aBcDeFgHiJkLmNoPqR",
  "conversation": "2fT8kMxQ1aBcDeFgHiJkLmNoPqR",
  "sequence": 0,
  "time": "2026-08-15T11:30:00Z",
  "sender": {
    "name": "ops-console"
  },
  "prompt": "Which streams have no consumers?"
}

On a request, id, request and conversation all hold the same value. sender.name is limited to letters, digits, - and _.

$ ajc tasks add fisk-ai:run --payload-file request.json --queue FISK_AI
Enqueued task 3Hwxl119ZbwKHCKPzWlslXZYnB0

The submitter supplies the task id, or the engine mints one. The worker hashes it with the serving identity to get the session the run journals under, so a job creates a session or resumes one an earlier delivery of the same task made, and reaches nothing else on the worker. Every id the queue accepts works, a leading dash and a colon included.

Optional fields narrow what one job may do:

FieldDescription
contextsupporting material offered alongside the prompt
budget.max_tokenslowers the token budget for this job
budget.max_iterationslowers the model-call cap for this job

A budget may only lower what the configuration allows. A value above the configured limit is ignored.

The worker refuses a payload it cannot run and does not retry it, recording the reason in the task’s LastErr. This covers:

  • an oversized payload
  • a payload that is not a valid v1 request
  • a payload that is not an io.choria.fisk-ai.v1.request.prompt, or whose prompt is empty

Reading the answer

The answer is stored on the task itself as a v1 result message:

$ ajc tasks view 3Hwxl119ZbwKHCKPzWlslXZYnB0 --json
{
  "protocol": "io.choria.fisk-ai.v1.result",
  "request": "2fT8kMxQ1aBcDeFgHiJkLmNoPqR",
  "sender": {
    "name": "worker"
  },
  "recipient": {
    "name": "ops-console"
  },
  "stop_reason": "end_turn",
  "text": "ORDERS and PAYMENTS have no consumers.",
  "usage": {
    "input_tokens": 1608,
    "cache_read_tokens": 663,
    "output_tokens": 51,
    "llm_calls": 2,
    "tool_calls": 1
  }
}

The request field echoes the id the caller submitted, and recipient names the caller that asked. input_tokens counts every input token the job consumed. cache_read_tokens and cache_create_tokens are subsets of that total, not additions to it.

A failed run is still a completed job. The worker stores a v1 error message with a stop_reason and acknowledges the task. It is not retried: a model refusal or an exhausted budget fails the same way on redelivery.

Stop reasonMeaning
end_turnthe agent finished and answered
budget_exhaustedthe token budget ran out
max_iterationsthe model-call cap was reached
suspendedthe run stopped at a point it can resume from
errorthe run failed

Where the failure is one a caller can act on, the stored error also carries a code beside its stop_reason. It is the same vocabulary the prompts protocol sends on a terminal message, and these are the ones a job reaches:

CodeMeaning
provider_busythe model provider had no capacity or refused a rate-limited call; submit the same job again shortly
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

A job whose session journal another writer holds reaches none of these: the run ends with no outcome, so the worker returns the job to the queue and stores no answer at all, and the redelivery runs it.

An error with no code is a failure this vocabulary does not name, and the message text is what says how it failed.

Redelivery

The worker journals every run under the session its task id derives. When a worker dies mid-job, the redelivery derives the same session and resumes that journal instead of starting again.

A job whose session already completed is answered from the journal, without running the agent or calling the model. This is the case when a worker finished a job and died before acknowledging the task.

Note

Deploying a changed tool set while jobs are in flight fails their resume check, and those jobs are retried until the queue’s try limit and then expire. Drain a worker before replacing it.

Configuration

Every field under expose.agent.jobs has a default, so an empty block is valid.

expose:
  agent:
    jobs:
      # The work queue to consume. It must already exist.
      queue: FISK_AI
      # The task type this worker handles. Tasks of another type on the
      # same queue are left alone.
      task_type: fisk-ai:run
      # How many jobs this process runs at once. The --workers flag
      # overrides it.
      workers: 1
      # The NATS context the queue is reached over, defaulting to the
      # top-level nats_context. It is dialed separately, so the queue may
      # live on a different cluster from the session store.
      nats_context: production
      # Bounds a task payload in bytes before anything decodes it.
      max_payload: 524288
FieldDescription
queuework queue to consume, default FISK_AI
task_typeasyncjobs task type handled, default fisk-ai:run
workers (int)jobs run at once, default 1
nats_contextNATS context for the queue, defaulting to the top-level nats_context
max_payload (int)payload cap in bytes before decoding, default 524288

A worker only claims tasks of its configured task_type. Submit a different type and the task stays in the queue until it expires, with no error logged at either end.

Safety

Publish permission on the queue is the only access control. Anyone who can enqueue a task of the configured type runs the full agent loop with prompt text of their choosing, against every tool the configuration allows. Restrict publish permission on the queue’s subjects the way any other NATS resource is restricted.

The rest of what applies to any served run is covered in Serving.

Slack

The Slack channel hosts the agent behind a Slack bot. Somebody mentions the bot, a thread opens, and that thread is one conversation for as long as people keep mentioning the bot in it. A question the agent asks is posted as a message with buttons, and it may be answered days later.

Note

The channel is opt-in. The configuration must carry an expose.agent.slack block, and the worker reads SLACK_APP_TOKEN and SLACK_BOT_TOKEN from the environment.

The connection is Slack’s socket mode, so the worker listens on no address and needs no public URL.

Creating the Slack app

Create an app at api.slack.com/apps from this manifest:

display_information:
  name: Fisk AI
features:
  bot_user:
    display_name: fisk-ai
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - chat:write
      - groups:history
      - users:read
settings:
  event_subscriptions:
    bot_events:
      - app_mention
  interactivity:
    is_enabled: true
  socket_mode_enabled: true

Socket mode is the transport the channel connects over. app_mention is the only event it subscribes to. Interactivity carries the button presses and the answers typed into a question back, so without it an answer to a question never reaches the worker.

ScopeWhat it covers
app_mentions:readreceiving the mention that opens or continues a thread
chat:writethe status message, the answer, the questions, the notes
channels:historyreading the conversation around a mention in a public channel
groups:historythe same in a private channel
users:readresolving a user id to the name the model and the log see
Warning

Changing an app’s scopes or events does nothing until the app is reinstalled to the workspace. A worker whose bot token predates the change starts, connects, and then fails calls it has no scope for.

The credentials come out of the app:

TokenWhere it comes fromValue
SLACK_APP_TOKENBasic Information, an app-level token with connections:writestarts xapp-
SLACK_BOT_TOKENOAuth and Permissions, the bot user OAuth tokenstarts xoxb-

Neither appears in the configuration file. A missing one fails at startup naming the variable, and a token Slack refuses fails there too: the worker calls auth.test before it accepts anything.

Invite the bot to a channel with /invite @fisk-ai, then mention it.

Starting a worker

identity: helper
application_path: /usr/local/bin/nats
system_prompt: |
  You inspect NATS servers for the people in this Slack workspace. Answer concisely.
include:
  tools:
    - ^stream_
expose:
  agent:
    slack: {}
$ export SLACK_APP_TOKEN=xapp-...
$ export SLACK_BOT_TOKEN=xoxb-...
$ fisk serve --config slack.yaml
Serving helper/1.2.0:

         Endpoints: slack
             Model: claude-sonnet-5
          Sessions: file
         Knowledge: disabled
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s

A Slack turn runs the whole agent loop, so identity, system_prompt and llm.model are all required. The waiver that lets a tool-serving configuration omit them does not apply.

What a thread shows

A mention starts a turn, and that turn posts a status message it edits while the run works: :thinking_face: Thinking..., :hammer: Calling tools..., :books: Searching knowledge.... The message names a family of tools rather than the tool being run, because everybody in the channel reads the thread.

Each line opens with an emoji, so a thread scrolled past shows which turns worked and which did not before anybody reads the words:

The turn isThe line reads
waiting for a worker:hourglass_flowing_sand: Queued...
thinking:thinking_face: Thinking...
using the memory tools:brain: Accessing memory...
using the knowledge tools:books: Searching knowledge...
using any other tool:hammer: Calling tools...
waiting on somebody:question: Waiting for an answer...

The emoji is part of the line, so the notification a phone shows carries it too.

The status message carries a Stop button while the turn is running, and anyone in the thread may press it. The bot finishes the step it is on and stops there. Everything the thread has said is kept, so mentioning the bot again carries on from that point rather than starting the conversation over.

The answer is posted as a message of its own, and the status message becomes a link to it. Slack sends no notification for an edit, so a turn that answered by editing its own status message would have pinged somebody with Thinking... and told them nothing.

The answer goes out as markdown for Slack to render. The channel cuts it at 12,000 bytes and ends it with a note where it did not fit. Everything else the channel says is plain text it wrote itself.

no_progress turns the status message off. The answer, the questions and the refusals are posted either way, and the Stop button goes with the status message.

Who is speaking

Every line the model reads is prefixed with the speaker, as their name and the markup that addresses them:

Ana Silva <@U024BE7LH>: the deploy went out at four
Ben Cole <@U0LM3D6TP>: and disk climbed right after

The name comes from the profile: the real name, then the display name, then the handle. It falls back to the user id under all three, and to the id alone where users:read was not granted, which the worker logs as a warning the first time it resolves each person.

The markup is what notifies somebody. Slack sends a notification for <@U024BE7LH> and none for a name written out, so an answer that addresses people by name reaches nobody’s phone. The bot does not use it unless you say so, which is a line in system_prompt:

system_prompt: |
  You inspect NATS servers for the people in this Slack workspace. Answer concisely.

  Each line of the conversation is prefixed with the speaker's name and their Slack id, as
  "Ana Silva <@U024BE7LH>". When you address someone, write that <@...> markup rather than
  their name, so they are notified.

Questions

A tool that needs a person asks in the thread, as a message of its own. It opens by mentioning whoever started the turn, so they are notified, and anybody in the thread may answer whether or not they asked the question.

QuestionThe thread shows
a yes/no questionYes and No
a confirmation gateAllow once, Allow for this conversation, and Decline
a selectionthe options as a numbered list in the message, and a button per number under it
a free-text questiona field to type the answer into, sent by pressing enter

The first three carry Dismiss beside them, which answers the tool that a person was reached and gave no answer. The gate’s Decline is its dismissal: the gated command not running is the whole of what declining a gate can mean.

A selection puts the options in the message rather than on the buttons because a button label is cut at 75 characters, where the message holds 3000. Twenty-five options share those characters, so a long option is cut to its share of them and every option is on the list.

Nothing expires. The question stays in the thread until somebody answers it, and the bot carries on from that answer whenever it arrives: a minute later, on Thursday, or after the worker has been restarted in between. The status message reads :question: Waiting for your answer. in the meantime, and the question message records who answered and what they chose.

answer_grace is how long the bot stays on that thread before it goes back to answering other people. It changes nothing about how long an answer is accepted for.

The worker never sees a plain reply in the thread. Only app_mention is subscribed, so words typed under a question reach it through the question’s own field or through a mention and no other way, and every question message says so. A mention answers a free-text question; while any other kind is open the channel refuses the mention with a link to the question.

Warning

Allow for this conversation on a confirmation-gated command covers the whole thread, not one turn and not the person who pressed it. Anybody who can mention the bot in that thread runs that command from then on.

How a turn ends

The status message says what became of the turn:

EndingThe thread shows
the agent answeredthe answer as its own message, and :white_check_mark: Done: see the answer
the agent answered nothing:white_check_mark: I finished, but had nothing to say.
a question is unanswered:question: Waiting for your answer.
Stop was pressed:octagonal_sign: Stopped. Mention me in this thread to carry on.
a gate was never approved:octagonal_sign: Nobody answered my question in time, so I stopped. Answer it and I will carry on.
the worker drained:octagonal_sign: I was shut down part way through. Mention me to carry on.
the token budget ran out:octagonal_sign: This conversation has used its allowance. Start a new thread to carry on.
the model-call cap was reached:octagonal_sign: I ran out of steps on this one. Mention me to carry on.
the run failed or crashed:x: and one line saying so

The budget and the step cap read as parked rather than as faults, since both lines tell the person where to carry on. A run that finished with nothing to say reads as answered, the run having finished.

No ending names a session, a tool call or a Go error. The worker log has all three, and a thread is read by everybody in the channel.

Where something went wrong on the way to the answer, such as a tool that ran out of time or a memory index the bot could not read, one message under the answer says so in a sentence. It names the kind of problem and nothing a tool returned.

Capacity

workers turns run at once. A mention that arrives with no slot free shows Queued... until one frees. Once max_waiting threads are already waiting, the next mention gets a short reply asking the person to come back in a few minutes, which is better than watching a queued message for three of them.

Three lines typed in ten seconds are one thought, so further mentions from the same person reach the bot as one follow-up turn, up to max_coalesced messages. A mention from somebody else gets a turn of its own behind that one, with its own status message and its own answer.

A turn that ended waiting on a question, or that somebody stopped, cannot take those extra lines. The thread gets them back as I did not get to: ..., so the person can see which of their messages went unanswered and send them again.

Shutdown and faults

A drain stops the channel taking mentions. A thread whose turn was still waiting is told the turn will not run, and a turn already going stops where the next mention can carry it on and says so on its status message. The connection closes last, so a turn still finishing keeps receiving stop presses and answers to its questions.

The socket mode client reconnects on its own, so a dropped connection is logged and waited out. A revoked or invalid token is a fault: fisk serve drains and exits non-zero, and a supervisor restarts the worker.

Warning

A worker that dies mid-turn leaves a status message that never changes again. Slack does not send the mention a second time, and nothing tidies the message up at startup. The conversation survives: the next mention in that thread carries on from what the journal holds.

One worker per bot token

Run one fisk serve per bot token. Slack allows an app up to ten socket connections and spreads envelopes across them, which this channel cannot use: the threads it is running and the questions it is holding are in process memory, so a button press delivered to a process that holds neither reaches nothing.

A press that lands on a worker with no record of the question still works, because the interaction is self-describing and the session derives from the thread. Everything else, from a Stop press to a mention folded into a running turn, needs the process that holds the turn.

Sessions

The session is a hash of the serving identity, the team, the channel and the thread, so two agents in one workspace keep their conversations apart and one agent keeps two threads apart.

A thread is a conversation, so the channel needs a session store it can read: fisk serve refuses to start the Slack channel without one. Threads outlive workers, so a deployment across machines wants a shared harness.sessions backend rather than the default file store.

Configuration

Every field under expose.agent.slack has a default, so an empty block is valid.

expose:
  agent:
    slack:
      # Turns this process runs at once. --workers does not reach it.
      workers: 5
      # Messages of surrounding conversation a turn reads.
      context_lines: 20
      # Turns off the status message, and the Stop button with it.
      no_progress: false
      # How long a question is held before the run defers.
      answer_grace: 30s
      # Admitted turns waiting for a worker before a mention is refused.
      max_waiting: 10
      # Messages folded into one follow-up turn.
      max_coalesced: 5
FieldDescription
workers (int)turns running at once, default 5
context_lines (int)surrounding messages a turn reads, default 20
no_progress (boolean)turns off the status message and the Stop button, default false
answer_grace (duration)how long a question is held before the run defers, default 30s
max_waiting (int)admitted turns waiting for a worker, default twice workers
max_coalesced (int)messages folded into one follow-up turn, default 5

workers defaults to 5 where the other channels default to 1. A thread is a person waiting, and with one worker the second person to ask anything watches a queued message until the first person’s run finishes.

--workers sizes the queued-jobs intake and does not reach this channel. context_lines covers both reads a turn makes: the conversation around a mention that opens a thread, and what was said in a thread since the bot last replied.

Safety

Channel membership is the whole of the access control. Anybody who can see a channel the bot is in can run the full agent loop against every tool the configuration allows, and can answer any question the agent asks there. Whoever can invite the app decides who reaches the tools.

Every run records its caller as the Slack username and user id, and nothing consults that record for a decision.

The rest of what applies to any served run is covered in Serving.

Web

The web channel hosts the agent behind an HTTP listener a browser reaches. 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. The turn runs the same agent loop every other channel runs.

Note

The channel is opt-in. The configuration must carry an expose.agent.web block, and that block must list at least one origin. Available since Version0.0.6.

Warning

Nothing on these routes is authenticated. Anyone who reaches them can read, continue and delete any conversation this channel holds.

Two frontend protocols are mounted, the Vercel AI SDK UI message stream and AG-UI, and the configuration chooses neither: turning one off prevents nothing an enabled one allows. The routes, the request and response shapes, the status codes and where this channel departs from each protocol are in the web protocol.

Frontends

The AI SDK UI message stream answers on <base_path>/vercel. Vercel’s useChat reads it, and AI Elements is the component set built on that hook: chat furniture vendored into a page as source by the shadcn command rather than imported as a library. This format was built first because an agent that runs commands wants components that already exist. A page needs its transport pointed at this channel’s endpoint and nothing more, and a stock frontend works unchanged: it sends its whole history, and the worker answers from its own journal instead.

AG-UI answers on <base_path>/agui, and two third-party frameworks read it. assistant-ui speaks it through its own AG-UI package and posts at the endpoint directly, with nothing between the page and the worker. CopilotKit runs a runtime route of its own, so a deployment on it puts that route in front of the worker.

The two differ on how a page approves a confirmation-gated command. An AG-UI client draws the question as an interrupt, and the standing allow, running the command and not asking about that tool again, is one of the three answers the protocol carries. The AI SDK’s approval is a boolean, so the standing allow and the three human-in-the-loop questions travel in a field of Fisk’s own; a client that never sets that field still works, with an approval that runs the command once or refuses it.

assistant-ui hands an interrupt to the page rather than drawing it, so a console built on it writes the panel that renders each question and submits the answer.

Starting a worker

identity: nats-ops
application_path: /usr/local/bin/nats
system_prompt: |
  You inspect NATS servers for whoever is at the console. Answer concisely.
llm:
  model: claude-sonnet-5
include:
  tools:
    - ^stream_
expose:
  agent:
    web:
      listen: 127.0.0.1:8080
      base_path: /fisk/v1
      origins:
        - http://localhost:5173
$ fisk serve --config web.yaml

The banner names the address, the pages allowed to read the answers, every route it mounted and how many turns it runs at once:

Serving nats-ops/1.2.0:

         Endpoints: web
             Model: claude-sonnet-5
          Sessions: file
         Knowledge: disabled
       MCP Clients: none configured
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s

  Answering in a browser:

            Listen: 127.0.0.1:8080
         Base Path: /fisk/v1
           Origins: http://localhost:5173
            Routes: POST /fisk/v1/vercel, GET /fisk/v1/vercel/sessions/{id}, POST /fisk/v1/agui, GET /fisk/v1/agui/sessions/{id}, GET /fisk/v1/card, GET /fisk/v1/sessions, DELETE /fisk/v1/sessions/{id}
           Workers: 5

The address is the bound one, so a configuration naming port zero prints the port the system gave it.

A web turn runs the whole agent loop, so identity, system_prompt and llm.model are all required. The identity must be one an operator chose: it is hashed into every journal this channel writes, so a name derived from the application or left at the default is refused at startup.

The listener binds while the worker starts, so a port already in use fails before the banner.

Configuration

expose:
  agent:
    web:
      # Host and port to bind.
      listen: 127.0.0.1:8080
      # Path prefix every format is mounted under.
      base_path: /fisk/v1
      # The pages allowed to read the answers. Required.
      origins:
        - http://localhost:5173
FieldDescription
listenhost and port to bind, default 127.0.0.1:8080
base_pathpath prefix the formats are mounted under, default /fisk/v1
origins (array)pages allowed to read the answers, required and with no default

listen defaults to loopback because an empty address binds every interface on port 80.

base_path must start with a slash. fisk serve refuses fisk/v1, since Go’s ServeMux reads a pattern with no leading slash as a host and a path, registers it, and answers 404 to every request.

origins names each page as a browser sends it in the Origin header: scheme, host and optional port with no path. There is no default and no wildcard, so nothing reaches this channel until an operator names where the console is served from. An entry of *, an entry carrying a path, and a block with no entries at all are each refused at startup.

Five turns run at once, with no key to change it. A request arriving above that is refused with 503 and a Retry-After: 5 header rather than left holding an open response.

The agent card

GET <base_path>/card answers the agent’s self description: its identity, its version, the model that answers a prompt, the tools it holds and the behavior each declares. A console reads it to draw the agent. What an operator writes into it sits beside identity rather than inside the web block:

identity: nats-ops
display_name: NATS Operations
description: Inspects and operates the production NATS cluster.
icon_url: https://example.net/nats.png
prompts:
  - who can publish to ORDERS?
  - what changed in the auth config today?
KeyDescription
descriptionwhat the agent is for, in the operator’s own words
display_namethe human name, where identity is the name a caller addresses; up to 128 bytes
iconan emoji drawn beside the name, up to 16 bytes
icon_urlan https URL of an image drawn beside the name, up to 512 bytes
promptsthings a person can ask this agent, up to 8 of up to 256 characters each

A reader with no display_name shows the identity. A console offers the prompts to somebody who has never used the agent, so they read as the words to send rather than as a description of what it does. icon_url is a claim this agent makes and nothing verifies: the card is checked for an https scheme, a host and the length, and whether to fetch the image is the console’s decision.

The card is built for each request, so a worker restarted with a different model or a different tool set answers with what it is running now. A value the card cannot carry fails there: an icon_url that is not https or names no host, a display_name, an icon or a prompt over its limit, and a ninth prompt. The channel answers the request 500 and logs the value.

Note

The a2a endpoint builds its card once and refuses a bad value at startup, so the worker does not start. The web channel refuses it per request, so the same value starts the worker, answers every other route, and fails only on the card.

What a turn logs

Each turn writes one line when it ends, A turn ended, carrying the reason it ended, the error where there was one, and whether it ended on a question. Every line the channel writes carries channel=web, the session the thread runs in, the caller the request claimed and the turn id. A turn that put a question logs that too, with the kind of question and the tool call it belongs to.

The reasons are completed, suspended, error, budget and max_iterations. A turn that ended on a question reads as suspended: the run stopped where the next request continues it.

A refused request is logged too, naming the origin, the Host or the worker count it arrived above.

Deployment

Nobody is authenticated

Every request is anonymous. Neither format reads a caller name a request can be held to, 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.

On loopback that is adequate. Anywhere else it needs an authenticating proxy in front of the listener, which is the only thing that keeps one person out of another person’s conversation.

TLS goes in front

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.

Several workers

Several fisk serve processes may sit behind a load balancer. The channel holds nothing per thread: a thread is a session in the store, an answer to a question lives for the one turn that carries it, and a second turn on a thread already running is refused by the journal’s own claim rather than by a map in one process.

Those workers need one journal they all read. The file backend is a directory on one machine, so two workers on it 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 and the Host header

The worker enforces the origin list itself, before it reads a request body. CORS decides who may read an answer, while a cross-origin POST still reaches a handler and still runs a turn, the browser only withholding the answer from the page. A request carrying an Origin outside the list is refused 403. A request carrying no Origin is not a browser’s cross-origin request and passes.

While the listener is on a loopback address the worker also checks the Host header, and refuses one that does not name a loopback address on the bound port. That stops a page on the internet reaching the listener through a name its own DNS resolved to loopback. On any other address the check is off: the proxy in front owns the public name and passes the browser’s Host through unchanged.

Sessions

A conversation is a session in the store. The channel hashes the serving identity with the thread id the page sent, and stores it under a w- prefix, 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.

Listing, opening and deleting all filter to that prefix and to the identity on the journal, so one store shared by several channels keeps a Slack thread and a peer’s prompt out of the browser’s list, and two agents on one JetStream stream keep their conversations apart. A conversation journaled before the identity field existed carries none, and the listing shows it.

A thread is a conversation, so the channel needs a session store it can read and fisk serve refuses to start it without one. The endpoints that list, open and delete are in the web protocol.

Safety

A web turn is a full agent loop driven by prompt text from an unauthenticated request, running every tool the configuration allows. Reaching the listener is the whole of the access control, so an operator decides who may reach it, with the origin list deciding only which page may read the answers.

Nothing the channel writes is escaped for a browser. Tool output, tool arguments, the model’s own text and the command line a confirmation gate shows are all model-supplied or tool-supplied, and a console escapes them before they reach the page.

The rest of what applies to any served run is covered in Serving.

Serving tools

The a2a endpoint exposes the wrapped application’s commands to other agents over NATS as callable tools. A peer reads the agent’s card and invokes one of them. No prompt is sent and the agent loop does not run, so the model, budget and session settings do not apply.

Note

The endpoint is opt-in. The configuration must set expose.agent.a2a.serve_tools: true, otherwise fisk serve exposes no tools over a2a.

Serving tools is a endpoint of fisk serve since Version0.0.5. The fisk a2a command is gone, and a configuration carrying the old expose.agent.agent_to_agent key is refused at startup.

Serving a set of tools

identity: nats-tools
application_path: /usr/local/bin/nats
nats_context: production
include:
  tools:
    - ^stream_
expose:
  agent:
    a2a:
      serve_tools: true

A worker serving only tools needs no system_prompt and no llm.model. It does need application_path: built-in tools are never served, so an agent with no wrapped application serves nothing.

$ fisk serve --config tools.yaml
Serving nats-tools/1.2.0:

        Endpoints: a2a
    Agent Context: production
        Telemetry: disabled

  Serving tools over a2a:

          Discovery: choria.fisk-ai.discovery.nats-tools
              Tools: choria.fisk-ai.tool.nats-tools
        Concurrency: 4
       Tool Timeout: 30s
            Exposed: stream_add
                     stream_ls
                     stream_info

Reading the card

fisk discover fetches the card the worker answers discovery with:

$ fisk discover nats-tools --config peer.yaml

Agent Card for nats-tools:

        Agent: nats-tools
      Version: 1.2.0
    Protocols: io.choria.fisk-ai.v1

Importing those tools into another agent is remote_tools in that agent’s configuration, covered in the Reference.

Tool selection

expose.agent.tools applies on top of the agent’s include and exclude, and each filter matches every tool by name or, for a command, by tag. One file can run every stream_ tool in a job and serve two of them to peers.

A tool carrying ai:confirm or a configured confirm tag is left off the card, because no operator is behind a served call to approve it. Use ai:deny to keep a command out entirely.

Built-in tools are never served, since none declares a2a exposure. The startup banner lists the enabled ones as withheld, each with its reason.

Limits

expose:
  agent:
    a2a:
      serve_tools: true
      max_concurrent_tools: 4
      tool_timeout: 30s
      request_timeout: 120s
SettingDescription
max_concurrent_tools (int)tool calls run at once; default is the CPU count clamped to 2 to 8
tool_timeout (duration)limit on one call this agent answers; default 30s
request_timeout (duration)wait for a peer’s next message; default 2m, minimum 30s

In a container the concurrency default reads the container’s CPU limit, not the host’s, and the banner prints the concurrency in use. --workers sizes the queued-jobs intake and does not reach these. harness.tool_timeout limits a tool call inside the agent loop.

request_timeout limits a call this agent makes to a peer. The peer answers with a set of messages: an acknowledgement, a keepalive every ten seconds while the tool runs, then the reply. The timeout applies to the gap between those messages, so this agent waits for as long as the keepalives arrive, and harness.tool_timeout ends the call. A card fetch is a single message, so the same value covers a whole card fetch.

The same value is how long the prompts endpoint holds a question it put to a caller, see Answering questions. Raising it for a slow peer raises that window too.

An agent that only calls other agents still needs request_timeout, so a block holding nothing else is valid:

expose:
  agent:
    a2a:
      request_timeout: 30s

Any other expose.agent.a2a block must set serve_tools: true or a prompts block, or fisk serve exits with an error.

The server refuses a call that arrives with every slot in use rather than queueing it. The ack carries accepted: false, and the tool.reply carries is_error: true with code: capacity. The identity is a NATS queue group, so a retry is delivered to whichever member is free next.

Running with other endpoints

A single fisk serve process runs a channel and this endpoint on one NATS connection:

Serving nats-worker/1.2.0:

         Endpoints: asyncjobs/FISK_AI
                    a2a
             Model: claude-sonnet-5
     Queue Context: production
          Sessions: file
         Knowledge: disabled
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s
     Queue Workers: 1 (config)

  Serving tools over a2a:

           Discovery: choria.fisk-ai.discovery.nats-worker
               Tools: choria.fisk-ai.tool.nats-worker
         Concurrency: 4
        Tool Timeout: 30s
             Exposed: stream_ls
                      stream_info

Adding a prompts block to the same a2a block also answers prompts, covered in Answering prompts.

Shutdown and faults

A drain stops the endpoint answering and removes the identity from its queue group, so the worker takes no further tool calls. Prompts stop with it, both endpoints using one transport and one identity.

A drain does not wait for a call that is already running. The call runs to completion with nowhere to reply to, and a command it started may outlive the worker. tool_timeout stops a call that does not finish.

An error on any of the service’s subscriptions stops the whole micro service, taking discovery, tools and prompts for that identity down together. fisk serve logs it, drains the runs in flight and exits non-zero, so a supervisor restarts the worker. A drain stops the service by the same path, and is logged rather than reported as a fault.

Safety

Whoever can publish to choria.fisk-ai.tool.<identity> can run every tool on the card. NATS permissions are the whole of the access control: a served call carries no verified caller, so nothing can distinguish one peer from another.

The tool safety rules described in the Reference hold here as everywhere else: commands run as an argument vector rather than through a shell, each argument is checked against the command’s schema, and credentials are stripped from tool environments.

Answering prompts

The prompts channel takes a prompt from another agent over NATS and runs the agent loop over it. The caller waits and receives an acknowledgement, then the events the run produces, then the answer or the failure.

Note

The channel is opt-in: without an expose.agent.a2a.prompts block fisk serve answers no prompts. Available since Version0.0.5.

The request and reply shapes, the event vocabulary, the error codes, cancelation and the question protocol are in the prompts protocol.

Configuration

identity: nats-worker
application_path: /usr/local/bin/nats
nats_context: production
system_prompt: |
  You operate NATS. Answer with what you did and what you found.
llm:
  model: claude-sonnet-5
include:
  tools:
    - ^stream_
expose:
  agent:
    a2a:
      serve_tools: true
      tool_timeout: 60s
      prompts:
        workers: 2

Answering a prompt runs the whole agent loop, so the configuration needs identity, system_prompt, llm.model and nats_context. application_path is optional: an agent with only built-in tools, or with none, still answers prompts.

identity must be one you wrote. It is the subject peers reach this worker on and the queue group it joins, so a name taken from the application binary or left at the default would put unrelated agents into one group, sharing each other’s work.

KeyDescription
workers (int)how many prompts this process answers at once, default 1
elicit (boolean)let a run put its questions to the caller, default false

Every field defaults, so an empty prompts block enables the endpoint.

$ fisk serve --config prompts.yaml
Serving nats-worker/1.2.0:

         Endpoints: a2a/prompts
                    a2a
             Model: claude-sonnet-5
     Agent Context: production
          Sessions: file
         Knowledge: disabled
         Telemetry: disabled
    Tool Directory: /var/lib/fisk-ai
      Tool Timeout: 5m0s

  Answering prompts over a2a:

            Requests: choria.fisk-ai.task.nats-worker
             Cancels: choria.fisk-ai.cancel.nats-worker.*
             Workers: 2
    Answering a prompt runs the agent loop and reaches every tool the top-level include and exclude selected.

  Serving tools over a2a:

           Discovery: choria.fisk-ai.discovery.nats-worker
               Tools: choria.fisk-ai.tool.nats-worker
         Concurrency: 4
        Tool Timeout: 1m0s
             Exposed: stream_ls
                      stream_info

workers is how many prompts the process answers at once. --workers does not change it; that flag applies to the queued-jobs channel.

elicit lets a run ask the caller the questions it would otherwise put to an operator at a terminal: whether a confirmation-gated command may run, and the three human-in-the-loop questions.

expose:
  agent:
    a2a:
      prompts:
        workers: 2
        elicit: true
Warning

Anyone who may answer this identity’s questions can approve a confirmation-gated command in a run. An answer carries no verified caller identity.

The worker holds a question for expose.agent.a2a.request_timeout, and its worker slot with it. That key sits on the a2a block rather than inside prompts, and raising it for a slow peer raises the question window too. What travels on the wire is in Answering questions.

Every agent that answers prompts also answers discovery, so fisk discover <identity> reports what this worker is running, described in Asking what an agent is.

Concurrency and shutdown

Each prompt holds a worker slot from acknowledgement until the run ends. No setting limits total run time; harness.tool_timeout limits a tool call and llm.budget.call_timeout limits a model call. With workers: 1, one long run makes the worker refuse every other caller until it finishes. A run whose caller keeps sending waiting is one such run, and it holds its slot for as long as the caller sends them.

An interrupt starts a drain, which takes the identity out of its queue group, so the worker accepts no further prompts. The worker waits for runs already under way and answers their callers. A prompt acknowledged but not started ends with draining, one of the codes in Refusals and endings. A second interrupt cancels the runs in flight and answers each caller with failed.

A drain stops restarting the window of a question already outstanding, so it ends within one window and the runs behind it finish. A caller sending waiting at that point still gets an ack, but the ack no longer restarts the window.

Tool selection

A run started this way reaches every tool the top-level include and exclude selected, exactly as a queued job does. expose.agent.tools selects what peers may invoke directly over MCP and a2a, and does not affect a run.

A command tagged ai:confirm, or a configured confirm tag, needs an approval before it runs. Without elicit the run has no operator to ask, so the model sees the tool, calls it, and the worker refuses the call before the command runs. The worker logs how many such tools the run loaded. With elicit the question goes to the caller, as Answering questions describes.

Sessions

The worker mints a conversation token and journals every run under its hash. A crash leaves a resumable run, and a deferred tool call has a journal to answer into. A caller holding the token continues that conversation; a caller that wants the work redone from scratch sends the prompt without one.

conversation on a request is echoed on every reply and never names a journal. It is the caller’s own correlation tag, free for grouping whatever it likes, and the token names the conversation.

Journals from this channel are named t- and a hash, so an operator reading fisk session ls can tell a prompt’s journal from a queued job’s. A session listing shows the last run’s outcome, so a conversation resting between turns reads as completed, and the prompt column shows the conversation’s first prompt.

The worker records the token and the caller’s claimed name with the journal, so a caller that lost a token can ask an operator for it instead of losing the conversation. Find the conversation with fisk session ls, which lists the first prompt and the time each journal was last touched, then read both values with fisk session show <id>. The listing has no token column, because a token is a credential and ls output is often pasted into tickets.

Safety

NATS publish permission on choria.fisk-ai.task.<identity> is the only access control: anyone holding it runs this agent’s tools against a prompt of their choosing. A request carries no verified caller identity, and sender is an unverified claim the worker records and logs.

A caller needs publish on the request subject. Canceling needs publish on choria.fisk-ai.cancel.<identity>.>, and answering questions needs publish on choria.fisk-ai.elicit.<identity>.>. The reply set arrives on the caller’s own inbox.

Anyone holding the cancel permission who learns a request id can cancel a run they did not start. Anyone holding the answer permission who learns a request id and a question id can approve a confirmation-gated command in a run they did not start, and can hold that run’s worker slot for as long as they keep sending waiting.

A conversation_token is a credential on the same terms: holding it is the authorization to add a turn to that conversation, and any holder can continue a conversation, whoever started it. It carries more than a fresh prompt does, because a standing approval an earlier turn recorded is restored with the conversation, so with elicit set a turn can reach a confirmation-gated command that somebody else approved. Tokens carry 128 bits of randomness and cannot be guessed, so treat one as a secret: this agent neither logs it nor puts it in an error message, and a caller should not either.

The session store is shared with the other channels of this identity, and each names its journals in a space of its own: a conversation here is a hash of the identity and the token, and a queued job is a hash of the identity and its task id. So a queue submitter that learns one of these journal ids and spells it as a task id gets a journal of its own rather than this conversation.

The worker records the token in the conversation’s journal, so anyone who can read the session store can read the token and continue that conversation. This gives away no access that reading the store did not already give, since the same access reads and writes those journals directly, but the store needs the same protection the tokens do. The caller’s name is recorded beside the token, and it is the unverified claim from the sender field.

The worker logs the caller, the request id and the session as a prompt is accepted, runs and ends:

level=INFO msg="Accepted a prompt" channel=a2a/prompts request=docs1 caller=peer1 session=3Hzq5OghUNlK8SnoBgVjfE6MKvx prompt_bytes=26
level=INFO msg=Running channel=a2a/prompts work=3Hzq5OghUNlK8SnoBgVjfE6MKvx caller=peer1 caller_verified=false resume=3Hzq5OghUNlK8SnoBgVjfE6MKvx
level=INFO msg="Ending a run" channel=a2a/prompts request=docs1 caller=peer1 session=3Hzq5OghUNlK8SnoBgVjfE6MKvx code=failed reason="llm call: 401 Unauthorized"

The worker logs the window it gave a question and, when the question closes, how long it held it and how many waiting messages the caller sent. An operator reads from these which caller is holding a worker, and for how long:

level=INFO msg="Asked the caller a question" channel=a2a/prompts request=docs1 caller=peer1 question=3Hzq7RvnWMtU0XstDiYlhG8OMxz kind=approve wait_ms=120000
level=INFO msg="A question was answered" channel=a2a/prompts request=docs1 caller=peer1 question=3Hzq7RvnWMtU0XstDiYlhG8OMxz held=14m32s acks=21

The tool safety rules in the Reference apply here as everywhere else.