A channel supplies work to an agent and returns the answer. A work queue and a NATS request subject are channels
today; an HTTP listener or a caller in the same process would be channels 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 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 and Slack 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:
Capability
Description
Streaming
whether a caller sees output as it is produced
Elicitation
whether a run can ask a person a question mid-run
Follow-up turns
whether a conversation continues after the first answer
Caller identity
what the channel reports about the caller
What each shipped channel offers:
Channel
Streaming
Elicitation
Follow-up turns
Caller identity
Queued jobs
no
no
no
unverified sender field
a2a prompts
yes
optional
yes
unverified sender field
Slack
no
yes
yes
the Slack user who spoke
No caller waits for a queued job, so that channel does not stream output and does not take a second turn.
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 covers the fisk serve command and the settings every channel shares
Queued jobs covers the asyncjobs channel: submitting work, reading answers, and its own configuration
Answering prompts covers taking prompts from other agents and streaming the run back to them
Slack covers answering people in a Slack workspace, where a thread is a conversation
Serving tools covers offering this agent’s tools to other agents without running an agent loop
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: workerapplication_path: /usr/local/bin/natsnats_context: productionsystem_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:
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:
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:
Item
Value
Queue
expose.agent.jobs.queue, default FISK_AI
Task type
expose.agent.jobs.task_type, default fisk-ai:run
Payload
an 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:
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:
Field
Description
context
supporting material offered alongside the prompt
budget.max_tokens
lowers the token budget for this job
budget.max_iterations
lowers 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:
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 reason
Meaning
end_turn
the agent finished and answered
budget_exhausted
the token budget ran out
max_iterations
the model-call cap was reached
suspended
the run stopped at a point it can resume from
error
the run 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
Field
Description
queue
work queue to consume, default FISK_AI
task_type
asyncjobs task type handled, default fisk-ai:run
workers (int)
jobs run at once, default 1
nats_context
NATS 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.
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.
Configuration
identity: nats-workerapplication_path: /usr/local/bin/natsnats_context: productionsystem_prompt: | You operate NATS. Answer with what you did and what you found.llm:
model: claude-sonnet-5include:
tools:
- ^stream_expose:
agent:
a2a:
serve_tools: truetool_timeout: 60sprompts:
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.
$ 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.
Making requests
A caller publishes a request on choria.fisk-ai.task.<identity>:
request names the turn. Every reply to it echoes the value, cancelling the turn addresses it by that
value, and so does answering a question the run asks, so pick it before you send and keep it. It must
name one turn and one only: two turns sharing a value make their replies indistinguishable, and a
cancel aimed at one of them stops both. It is at most 64 characters of letters, digits, - and _,
because a worker builds subjects from it.
id names the message rather than the turn, so it is fresh on every message including a resend, and
conversation is the caller’s own tag across the turns of one conversation.
Protocol
Asks for
Required
Also takes
io.choria.fisk-ai.v1.request.prompt
a turn: the agent runs the prompt
prompt
context, tool_hints, budget, stream, conversation_token, replay, force
io.choria.fisk-ai.v1.request.answer
a question answered and the run resumed
conversation_token, answer
budget, stream, replay, force
io.choria.fisk-ai.v1.request.resume
a run that stopped part way continued
conversation_token
budget, stream, force
io.choria.fisk-ai.v1.request.read
the conversation read back, no turn taken
conversation_token, replay
nothing
The queued-jobs channel takes a request.prompt as its payload and none of the other three.
context is supporting material offered alongside the prompt, stream: false asks for the answer without the event
stream, and conversation_token joins an existing conversation, see Follow-up turns.
force is a caller’s decision about its own conversation. Without it a worker refuses a resume across a changed model,
system prompt or tool set; with it the run continues under the current configuration and drops the standing approvals it
can no longer vouch for.
A budget above the worker’s own configuration is ignored. On a conversation it limits the conversation rather than the
turn, since a run measures the whole journal’s token count against it.
The reply set arrives on the request’s own inbox, in order:
Message
When
ack
once, first, saying whether the prompt was taken
event.<kind>
zero or more, carrying the run’s output as it is produced
elicit.request.<kind>
a question the run puts to the caller, only when elicit is set
result
the answer, with its stop reason and token usage
error
instead of a result when the run did not produce one
The acknowledgement comes first, so a plain nats req receives it and stops there:
Every message of the set carries sequence, numbered from the acknowledgement without gaps, so a caller can tell a
lost event from a quiet run. Events are advisory. The answer is in the terminal message, and the worker’s run journal
is the authoritative transcript.
Event blocks
Each event holds one block. Where an id under io.choria.fisk-ai.v1.event. is one your client does not recognize, keep
the message and render what you can rather than rejecting it: a newer worker sends kinds this one does not define.
Protocol
Fields
What it is
io.choria.fisk-ai.v1.event.text
text, final
the model’s prose
io.choria.fisk-ai.v1.event.thinking
text
the model’s reasoning, when it produces any
io.choria.fisk-ai.v1.event.tool_call
id, name, input
a tool the run is about to invoke
io.choria.fisk-ai.v1.event.tool_result
call_id, output, is_error
what that call returned
io.choria.fisk-ai.v1.event.agent_call
id, name, task
a question delegated to a peer agent
io.choria.fisk-ai.v1.event.warning
kind, name, count, params, error
an advisory the run raised
io.choria.fisk-ai.v1.event.prompt
text
a turn somebody asked for; sent only in a replay
io.choria.fisk-ai.v1.event.status
iteration, usage, phase, count, truncated
progress, and the markers around a replay
A text event in full:
{"protocol":"io.choria.fisk-ai.v1.event.text","id":"3Hzmp8kRt1BqA4dQ2v9XnLcYm2T","request":"docs1",
"conversation":"docs1","sequence":2,"time":"2026-08-16T11:24:11.104217Z",
"sender":{"name":"nats-worker"},"block":{"text":"the stream is gone","final":true}}
final marks the answer. Only the run knows which message ended the turn, so without the flag a caller cannot tell
the answer from the narration on the way to it, and would render it twice when the same text arrives again in the
result.
A warning names its kind and gives you the values, not a finished sentence. Your client chooses the wording, and
a client that does not recognize a kind can still display the fields.
A tool_call is not answered twice. A call the caller was asked to approve carries the same tool_use_id as the
elicit.request.approve that asked, so a caller that drew the question knows it has already shown that call.
Not every call produces a result: a denied confirmation, a tool called without its required arguments, a tool that
answers later and an aborted run each end without one, so a caller pairing the two tolerates a call that is never
answered.
A status block reports progress, and its usage is what one model call consumed. The call that ends a turn sends no
status of its own, so a caller keeping a running total takes the totals from the terminal message rather than summing
these. The replay markers use the same block and are described below.
Refusals and endings
The worker refuses a request it cannot parse with a NATS service error, before any acknowledgement:
Nats-Service-Error: the request is not a valid v1 message: jsonschema validation failed with
'https://choria.io/schemas/io.choria.fisk-ai.v1/request.resume.json#'
- at '/prompt': false schema
Nats-Service-Error-Code: 400
A resume takes no prompt. Send io.choria.fisk-ai.v1.request.prompt to run one.
Everything the worker refuses after that is an ack with accepted: false and a reason, followed by an error that
closes the set. The error carries a code the caller can branch on:
Code
Meaning
capacity
every worker slot is busy; retry, or ask another instance
duplicate_request
a run with this request id is already in flight here
draining
the worker is shutting down and never started this run
not_started
the prompt was taken and the worker stopped before running it
failed
the run ran and failed; the message says how
crashed
a bug in this software; the detail stays in the worker’s log
canceled
the run was stopped before it finished, by the worker rather than by the caller
suspended
the run stopped at a resumable point, which is what a caller’s own cancel reaches
deferred
a tool will answer later, so the run is parked
unknown_conversation
the conversation_token names no conversation here; send the prompt without one
conversation_busy
a turn of this conversation is running here; wait for its terminal message
turn_not_taken
the conversation could not take the turn, and the prompt did not run
budget_exhausted
the conversation has used its whole token allowance and is finished
unknown_call
no such call is waiting for an answer
already_answered
the call already has an answer
answer_too_large
the answer is over 256KB
The worker refuses at capacity rather than queueing the prompt. unknown_call, already_answered and
answer_too_large are permanent: sending the same answer again reaches the same reply.
budget_exhausted ends the conversation, not just this request. The allowance belongs to the conversation, so every
later turn is refused no matter who sends it. Your prompt did not run and was not recorded. To carry on, send a prompt
with no conversation_token to start a new conversation. Only an operator on the machine running the agent can raise
llm.budget.max_tokens.
You can also hit this straight away, on a conversation that answered a moment earlier, by lowering budget on your own
request below what the conversation has already used.
Every error also has a stop_reason beside its code. budget_exhausted appears there when a run hit the cap part
way through a turn instead of before it started.
A deferred run is waiting for a tool answer. The error lists the calls. Answer one on a request carrying the
conversation token, or with fisk session on the worker holding the journal.
Canceling
A caller cancels by publishing an io.choria.fisk-ai.v1.cancel on
choria.fisk-ai.cancel.<identity>.<request>, where request is the correlation id it sent. The request id is part of
the subject, so only the worker running that prompt is subscribed to it. It answers with an ack.
A cancel asks the run to stop where the conversation can be continued. It does not end the run where it stands: the
loop polls for it at each boundary and parks there, so the terminal message is suspended with the usage the turn
spent, and the conversation takes another turn whenever the caller sends one. A run blocked on a question is included,
since a cancel closes the question rather than leaving it asked with nobody to answer.
What that costs is the ability to stop a model call in flight. A run inside a tool that never returns reaches no
boundary, and a cancel will not move it; that escape hatch belongs to whoever operates the worker. A caller asks and an
operator compels.
Because the id is part of the subject, a caller mints it rather than being told it: set id and request on the
request before sending, so the tag is in hand before there is anything to cancel.
A no-responder error means this instance is not running that request: it was never accepted, it already finished, or
another instance took it.
Follow-up turns
A caller can send another turn of the same conversation. Every ack that accepts a prompt carries a
conversation_token, and a later request carrying that token runs its prompt as the conversation’s next turn:
{
"protocol": "io.choria.fisk-ai.v1.request.prompt",
"id": "3Hzmq7WdPK628XjRVZ8cLmBUTh4",
"request": "docs2",
"conversation": "docs1",
"sequence": 0,
"time": "2026-08-16T11:26:00Z",
"sender": {"name": "peer1"},
"prompt": "what is the first one called",
"conversation_token": "3Hzmp8VqrKL42NmXcPd7bTgWfR1"}
A caller that asks once and stops ignores the token the worker handed it; one that wants another turn sends the token
it already has. A follow-up opens a reply set of its own, with its own ack, events, cancel address and terminal
message, so it is an ordinary request in every respect but which conversation it joins.
No worker holds a conversation between turns. Each turn loads the journal, runs, and stores the result, so any
instance in the queue group serves any turn. That also means a caller sends one turn at a time: a second turn sent
while the first is still running is refused with conversation_busy, and it must wait for the first turn’s terminal
message rather than try another instance.
A conversation has no end state and no expiry: the journal stays in the session store until an operator removes it.
A turn cannot join a conversation waiting on a deferred tool result. The worker answers turn_not_taken without
running the prompt. With elicit set, a human-in-the-loop question the caller neither answers nor holds open within
request_timeout leaves the conversation waiting on a deferred call. Answer the question and it takes turns again.
A configuration change ends a conversation. Every turn is a resume, and the worker refuses a resume when the
model, the system prompt, the thinking mode or the reasoning effort has changed since the conversation started. It
answers failed and the caller starts a new conversation. A changed tool set does not end it: the turn runs, and
the standing approvals the conversation held are dropped, since an approval names a tool and that tool may have
moved under it.
The usage on a result counts the whole conversation rather than the turn, since it is read from the journal. An
error that ran and stopped carries it too, so a caller can tell what it owes for a turn it is about to continue.
Both also carry trace_id, the trace the worker recorded, which is empty when it exports no telemetry, and
content_exported, which says whether this turn’s conversation itself reached that collector.
Asking what an agent is
Ask an agent what it is before you send it anything. Run fisk discover <identity> to make that request. Every agent
that answers prompts also answers discovery, whether or not it serves tools as well. An agent that serves no tools to
peers answers with a card that lists none.
Two fields on the card describe what the agent does with a conversation:
Field
Meaning
telemetry
the agent exports traces of what it does
telemetry_content
those traces carry the conversation itself, so a prompt sent here reaches the operator’s collector
They are published because a caller should know before it sends a prompt, and they are read off the worker’s resolved
telemetry provider rather than its configuration, so a rejected endpoint does not leave the card promising an export
that will not happen. The card says what the agent is configured to do; content_exported on a terminal message says
what a turn actually did.
Reading a conversation
To read a conversation back, send an io.choria.fisk-ai.v1.request.read with a conversation_token and a replay
count. The worker sends that many blocks of the stored conversation and ends the reply set:
Use this to show a conversation your client did not see live, such as one started on another machine. A finished turn
leaves a completed journal, and a plain resume will not continue one, so reading it is the only request such a
conversation accepts until you send the next prompt.
You get back the same blocks the run sent the first time, between two status blocks:
phase: "replay_start" opens the history.
phase: "replay_end" closes it, with count blocks sent, truncated when older ones were left behind, and usage
for what the conversation has consumed so far. That usage is the whole conversation rather than one call, which is
what lets a caller seed a running total before this turn’s own calls arrive.
Set replay on each request that needs it. Leave it off a follow-up turn, which usually wants only the new blocks. The
worker sends at most 200 blocks whatever you ask for, and rounds up to a whole turn so that a result never arrives
without the call it answers. The largest useful value is therefore 200; ask for more and you get 200. A read asks for
at least 1.
Some of what the journal holds never leaves the worker: thinking signatures, the fingerprint, the caller, the
conversation token, the standing approvals, and the notes and handles of deferred calls. Long values are trimmed to fit
a block.
io.choria.fisk-ai.v1.request.resume continues a run that stopped part way, which is what a caller sends after a
suspended ending. Send it with the token and no replay.
Answering questions
A run puts a question back to the caller when it needs a person: an approval for a confirmation-gated command, or one
of the three human-in-the-loop questions. A run asks only when elicit is set.
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.
Reply under the id you were asked under, with request swapped for reply:
Protocol
Field
Values
io.choria.fisk-ai.v1.elicit.reply.approve
choice
no, once, always
io.choria.fisk-ai.v1.elicit.reply.confirm
confirmed
true, false
io.choria.fisk-ai.v1.elicit.reply.select
index
a position in options
io.choria.fisk-ai.v1.elicit.reply.input
value
any string, empty included
io.choria.fisk-ai.v1.elicit.reply.no_operator
none
no operator is available
Send the field even when its value is the zero one. confirmed: false, index: 0 and value: "" are each an answer
somebody gave.
io.choria.fisk-ai.v1.elicit.waiting arrives on the same subject and is not an answer. It says the caller is holding
the question open, see Holding a question open.
The worker replies with an ack. An answer to a question it is not waiting on gets a 404, as does an answer sent
after the question’s window closed, and as does a waiting sent after the question was answered.
once runs the command that one time. always stops the worker asking about that tool for the rest of the run. no
and no_operator both leave the command unrun and tell the model the refusal is final.
The worker holds the question for expose.agent.a2a.request_timeout, and its worker slot with it. The question’s
wait_ms carries that number, so the caller knows how long it has. An unanswered question ends the run differently
depending on what asked it:
an approval leaves the command unrun, and the run ends with suspended
a human-in-the-loop tool leaves its call deferred, and the run ends with deferred
Both can be answered later, see Answering after the run ended. An operator on the
worker holding the journal answers a deferred call with fisk session instead.
Holding a question open
A person reading a command approval can take longer than two minutes. A caller with the question in front of somebody
sends an io.choria.fisk-ai.v1.elicit.waiting, and each one restarts the window:
Send a waiting every wait_ms / 3, starting when the question goes on screen. The window restarts when the worker
receives the message, so the remaining two thirds cover the round trip and one lost message. In Go,
a2a.NewWaitingAck(question, sender) builds the message and question.AckInterval() is the interval.
Stop before sending the answer. A waiting that arrives after the answer is refused, since the worker has finished
with the question.
A 404 means the question is gone: take it off the screen and send no answer, since that would be refused too.
A 400, or a question with no wait_ms, comes from a worker older than this feature. Answer inside the window
instead.
Send no_operator when the person walks away. waiting says somebody is there to answer, and no_operator ends
the question at once. Silence leaves the command unrun too, but only after a whole window.
The reply set is silent while the worker holds the question, so a client learns the worker is still there only from
the ack to each waiting.
A caller that sends no waiting either answers within the window or answers later, on a request of its own.
Answering after the run ended
A person closes a laptop with a question on screen. The waiting messages stop, the window runs out, and the run ends
suspended or deferred. The worker unsubscribes from choria.fisk-ai.elicit.<identity>.<request> with the task, so
an hour later their answer reaches no responder.
They send an io.choria.fisk-ai.v1.request.answer instead, with the conversation token:
Copy tool_use_id and kind from the question. A resumed run mints a new question_id, so the answer names the call
instead.
The answer object has kind and answer of its own. answer names the field holding the decision, and kind says
what that decision means where the value alone cannot: no_operator looks the same whichever question was asked, and
value serves both input and select.
Field
Value
tool_use_id
the call the question named
kind
approve, confirm, select or input
answer
choice for approve, confirmed for confirm, value for select and input, or no_operator
choice
no, once or always
confirmed
true or false
value
the text for input, and the chosen option for select
A selection names the option, not its position.
You get back the usual ack, events, and a result or an error. The conversation gains no turn. A deferred call
takes the answer as its result; an approval is asked again by the resume and answered from the request.
A 400 means the answer does not fit its kind, or the message has no token, or it came with a prompt.
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. 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:
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.
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.
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.
Scope
What it covers
app_mentions:read
receiving the mention that opens or continues a thread
chat:write
the status message, the answer, the questions, the notes
channels:history
reading the conversation around a mention in a public channel
groups:history
the same in a private channel
users:read
resolving 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:
Token
Where it comes from
Value
SLACK_APP_TOKEN
Basic Information, an app-level token with connections:write
starts xapp-
SLACK_BOT_TOKEN
OAuth and Permissions, the bot user OAuth token
starts 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: helperapplication_path: /usr/local/bin/natssystem_prompt: | You inspect NATS servers for the people in this Slack workspace. Answer concisely.include:
tools:
- ^stream_expose:
agent:
slack: {}
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 is
The 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.
Question
The thread shows
a yes/no question
Yes and No
a confirmation gate
Allow once, Allow for this conversation, and Decline
a selection
the options as a numbered list in the message, and a button per number under it
a free-text question
a 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:
Ending
The thread shows
the agent answered
the 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
Field
Description
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.
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.
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.
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. 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. Knowledge, memory and the human-in-the-loop tools declare no a2a exposure, and the
startup banner lists them as withheld when the configuration enables them.
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:
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.