Share
Specification

Chat (v1, proposed)

Chat is an optional ALP extension that lets a Front Office-style client hold a live, turn-by-turn conversation with a Runner, instead of only ever submitting Tasks and watching Jobs run to completion. It is proposed: the mechanism described here is not yet implemented by any Server, Runner, or Operator — see Relationship to Current Implementation.

Chat is deliberately two capabilities, not one, because "talk to the thing attached to my project" turns out to mean two structurally different requests:

  • Sometimes you want a coding agent with full repository/tool access, and the conversation is really the front door to a Task.
  • Sometimes you just want a model to talk to — no repo, no sandbox, no pipeline — using whichever model and token the Runner's owner has configured on their own machine.

Both share one mechanism (a Job opens a session; a Chat Channel carries the turns) and differ only in what rides on that channel and which capability string gates it.


Concepts

TermDefinition
Chat JobA Job whose purpose is to open a chat session rather than run a Station to a single completion. Long-running for the session's lifetime — one Chat Job per session, not per turn.
Chat ChannelThe outbound, Runner/Operator-initiated, bidirectional connection opened once a Chat Job is claimed. Carries every turn of the session. Transport is implementation-defined (see Transport).
chat-session:v1Capability string for Kind A — sandboxed coding chat. A Runner declaring it can spawn a full Operator/Agent/devcontainer per session, same as any other Station.
chat-llm:v1Capability string for Kind B — bare LLM passthrough. A Runner declaring it exposes an OpenAI-compatible chat-completions gateway directly, with no sandbox or repository involved.

Both strings are ordinary entries in the existing, open-ended capabilities: string[] already sent by Runners at registration and on every poll (08-runner.md Registration, openapi.yaml's Runner/ RunnerRegistrationRequest/JobPollRequest schemas) — no new field, no schema migration. A chat Job simply declares needs: ["chat-session:v1"] or needs: ["chat-llm:v1"] and is matched by the Server's existing capability-coverage rule (every string in a Job's needs must be present in the polling Runner's capabilities) exactly like any other needs entry, e.g. docker or gpu.


The Shared Model

Both kinds follow the same three-step lifecycle:

  1. Open — the first message of a chat starts a Chat Job. The Server queues it with needs: ["chat-session:v1"] or needs: ["chat-llm:v1"] depending on which kind the client is requesting. Only a Runner declaring the matching capability can claim it. This is one dispatch for the whole session, not one per turn — the 10-second short-poll (08-runner.md) governs how quickly a session opens, not the latency of turns inside it.
  2. Connect — once claimed, the Runner (Kind B) or the Operator it spawns (Kind A) opens the Chat Channel: an outbound connection back to the Server, scoped to the Job's ID. Direction is fixed and non-negotiable — see Invariants.
  3. Close — the session ends, the channel closes, the Job concludes (success/failure) and is reported exactly like any other Job outcome (08-runner.md).

What differs between the two kinds is what a Job is underneath (a Task on a Station vs. a bare dispatch) and what rides on the channel once it's open.


Kind A — `chat-session:v1` (sandboxed coding chat)

A Kind A Chat Job is a Task at a chat-kind Station, dispatched exactly like any other dispatch_worker Station (05-tasks.md, 06-transition-rules.md). The Runner spawns a full Station Operator, devcontainer, and Agent — same trust boundary and Runner/Operator security responsibilities as any other Job (11-security.md).

The Task/Station wrapper is intentional, not incidental: a coding chat can end with a real pipeline outcome (a Task ready to route onward, or seeded into a new Assembly Line), so it needs the same Task lifecycle, Transition Rules, and completion reporting every other Station has.

On the Chat Channel, three message types:

MessageDirectionPurpose
chat.messageServer → OperatorOne user turn. Delivered into the running Agent session — the reference delivery mechanism is literal terminal-input injection (tmux send-keys or equivalent), the same primitive already used for tool-permission answers (see Reference Implementation).
chat.replyOperator → ServerOne assistant turn. The Agent MUST emit this via an Operator-exposed tool call — the Operator MCP server is request/response only (09-operator.md), so a reply cannot be pushed to the Agent from outside; the Agent must call out. A chat_reply tool, a sibling of the required complete_station tool, is the natural home for this.
chat.endeitherEither side may end the session. The Operator MUST still call complete_station on exit (or fall back to the stop-hook path, 09-operator.md) so the Job concludes normally regardless of how the chat itself ended.

Session continuity across a dropped channel MAY reuse the existing --resume/session-id mechanism already used for other multi-Job Tasks (reference implementation: lastJobResumeSessionId), so a reconnect can resume the same Agent session rather than starting cold.


Kind B — `chat-llm:v1` (bare LLM passthrough)

A Kind B Chat Job is not a Task on a Station — there is no repository, no Operator, no Agent, and no pipeline outcome to route anywhere. It is a bare Job: dispatch, claim, channel, complete. The Runner itself — not a spawned Operator — opens the Chat Channel directly.

On the Chat Channel, the payload is a full OpenAI-compatible chat-completions request/response exchange: the Server forwards a POST .../chat/completions-shaped JSON body (messages, optional tools/tool_choice, stream: true) down the channel, and the Runner streams back OpenAI-compatible SSE chunks (including tool_calls, if the Runner's backend supports function-calling) exactly as if the Server had called /v1/chat/completions on the Runner directly.

The Runner is a dumb, verbatim forwarder to whatever backend its owner has configured locally — their own OpenAI or Anthropic key, a self-hosted model server, a multi-backend proxy, or anything else that speaks (or can be translated to) the OpenAI chat-completions wire format. The Runner MUST NOT be required to hold, log, or transform credentials on the Server's behalf, and the Server never sees or stores the backend credential — same "forward unchanged, never rewrite the caller's own key" posture as the existing pks-agent-gateway reverse proxy (see Reference Implementation).

Why OpenAI-compatible and not Anthropic's Messages API: the OpenAI chat-completions shape (including its tools/tool_choice function-calling contract) is the wire format the self-hosted/local-model ecosystem has converged on — Ollama, LM Studio, vLLM, llama.cpp, OpenRouter, and multi-backend proxies like LiteLLM all speak it natively, and even Anthropic now ships an OpenAI-compatible endpoint of its own for interop. Picking it maximizes which backends a Runner's owner can point their gateway at without the Runner having to implement two wire protocols.

Tool-calling stays symmetric with a normal hosted setup: tools are still defined Server-side (ALP-specific tools — e.g. create a task, query project state) and executed there; only the model differs — it happens to answer from the Runner's side of the channel instead of a Server-held provider key.

Per-turn model selection (optional)

A chat.completion.request's forwarded body MAY include the standard OpenAI model field to select which model answers that turn specifically, overriding whatever the Runner would otherwise default to. Runners MAY ignore it and always answer with their own fixed configuration — a client that never sets model sees no change in behavior.

To let a client discover what it can even ask for, the Chat Channel supports one more, optional, message pair, Kind B only:

MessageDirectionPurpose
chat.models.requestServer → Runner{ type, jobId, requestId } — ask which model ids the Runner can currently serve.
chat.models.responseRunner → Server{ type, jobId, requestId, models: string[] } — the answer, echoing requestId. models MAY be empty (e.g. no backend is currently authorized).

This pair is a one-shot metadata round-trip, not a completion — it MAY be sent at any point while the channel is open, independent of any in-flight chat.completion.request. A Runner that predates this feature, or otherwise doesn't recognize chat.models.request, simply never answers it, per this spec's existing "unknown frame type, ignore" forward-compatibility rule. The Server MUST treat a timeout waiting for chat.models.response as "model listing unsupported by this Runner," not as an error — the client's /model picker (or equivalent) degrades to "no models to choose from," not a failure state.


Invariants

  • MUST: the Chat Channel is always opened outbound, by the Runner (Kind B) or the Operator (Kind A). The Server MUST NOT require, and MUST NOT depend on, any inbound reachability into the Runner's network to open or maintain the channel. This holds for both kinds, with no exception.
  • MUST NOT: neither kind requires a reverse-tunnel/inbound-exposure mechanism. pks-agent-tunnel-style inbound reachability solves a different problem (exposing a Runner-hosted HTTP/TCP service to the internet) and is explicitly out of scope here — chat only ever needs the Runner/Operator to dial out, which it already must be able to do to reach the Server's poll endpoint in the first place.
  • MUST (Kind A only): the Operator MUST still call complete_station (or rely on the stop-hook fallback) regardless of how the chat itself ends, so the Job's outcome is always reported per 08-runner.md.
  • MUST NOT (Kind B only): the Runner MUST NOT be required to hold a real backend credential on the Server's behalf, and the Server MUST NOT need to see or store one — the credential lives entirely on the Runner's side of the channel, exactly like the Runner's existing role as the sole holder of privileged service credentials in 11-security.md, just without an intermediating credential server since there is no sandboxed Agent to scope a JIT token for.

Transport is Implementation-Defined

This document commits to the capability strings, the Job-opens-a-session model, and the message contracts above. It deliberately does not mandate a transport for the Chat Channel itself — WebSocket, SSE, gRPC-stream, or long-poll are all conforming choices, as long as the outbound-only invariant holds.

agentics.dk's own (reference, not required) choice is a WebSocket route, proxied through its existing ws-relay component, distinct from both the per-Job terminal-broadcast socket used for Station output streaming (09-operator.md) and the unrelated live-viewer chat feature at /api/lives/chat/ws. A different Server implementation is free to pick SSE, a raw TCP/yamux stream, or anything else that satisfies the invariants above.


Interaction with Existing Machinery

  • Runner polling/capability matching (08-runner.md): unchanged. chat-session:v1/chat-llm:v1 are ordinary capabilities entries; a chat Job's needs array is matched by the Server's existing capability-coverage rule with no new matching logic.
  • Job Outcome Schema (08-runner.md): unchanged for both kinds — a Chat Job reports success/failure like any other Job when it concludes.
  • Operator MCP server (09-operator.md): Kind A adds one optional tool (chat_reply) alongside the required complete_station; Kind B has no Operator at all, so nothing here applies to it.
  • Security model (11-security.md): Kind A is bound by the full sandbox/credential-server/egress-proxy model like any other Job. Kind B has no sandbox to protect — its analog of "the Agent never holds real credentials" is "the Server never holds the backend credential"; the Runner is the trusted party making the real call, same posture as pks-agent-gateway.
  • Fan-Out/Join (12-fan-out-join.md): unrelated. A Chat Job is never a fan-out parent or child; nothing here changes Settle Rules or Join Release.

Relationship to Current Implementation

Neither kind is implemented today. This document exists to fix the shape before code is written, prompted by a real Runner (pks-cli) already reporting a live capabilities array (alp_operator, git:push, git-distribute) with zero Chat capabilities and zero jobs to process. The intended reference implementations, once built:

  • Kind A — vibecast (Operator reference implementation) wires chat.message to the same tmux send-keys primitive it already uses for delivering tool-permission answers into a running Claude Code session, and adds a chat_reply MCP tool alongside its existing stop_broadcast/complete_station-equivalent tooling. pks-cli (Runner reference implementation) adds chat-session:v1 to its declared capabilities and spawns the Operator exactly as it does for any other Station.
  • Kind B — pks-cli adds chat-llm:v1 to its declared capabilities and a local gateway configuration (which backend/key to forward to). agentics.dk's Server fronts it with the Vercel AI SDK's createOpenAICompatible provider factory pointed at an internal forwarding route that relays requests down the Chat Channel to the claimed Runner and streams the SSE response back unbuffered — mirroring the existing pks-agent-gateway reverse-proxy pattern (projects/pks-agent-gateway/src/gateway/main.go), just server-initiated over an already-open channel instead of a plain HTTP hop.

Implementing Your Own Chat-Capable Runner

A compliant Runner implementing Kind A MUST:

  1. Declare chat-session:v1 in its capabilities array
  2. On claiming a matching Job, spawn a Station Operator exactly as for any other Job
  3. Have that Operator expose a chat_reply MCP tool (or equivalent) alongside the required complete_station, and deliver incoming chat.message turns into the running Agent session
  4. Report the Job's outcome per the standard Job Outcome Schema regardless of how the chat session itself ended

A compliant Runner implementing Kind B MUST:

  1. Declare chat-llm:v1 in its capabilities array
  2. On claiming a matching Job, open the Chat Channel directly (no Operator, no sandbox, no repository)
  3. Forward OpenAI-compatible chat-completions requests received on the channel to its configured backend verbatim, and stream the response back without buffering or holding a real backend credential the Server needs to see

A compliant Runner implementing Kind B MAY additionally answer chat.models.request with chat.models.response (see Per-turn model selection) and honor a per-turn model field on chat.completion.request; neither is required, and a Runner that does neither remains fully compliant.

A compliant Runner implementing either kind SHOULD support session reconnect (Kind A via the existing session-resume mechanism; Kind B by simply accepting a new Chat Channel connection for the same Job, since there is no session state to resume beyond the open connection itself).