Agent Client Protocol seats any client

Link Harness is an ACP agent: a client of the Agent Client Protocol opens a session on one of its conversations, prompts it, and is streamed the answer as it's written, its tool calls beside it. It is the only way in: Link's connections, lnk agent chat send and its way in, the messages from its schedules and subagents, all reach it so.

Connect

A connection is a WebSocket on the agent's endpoint port, at /acp, one JSON-RPC message to a text frame. It is opened as any request to the endpoint is: with the agent's endpoint key as a bearer token, and Link-Agent or Link-Agent-Id where the harness answers for more than one agent. Without the key, the WebSocket is refused before it opens.

initialize

initialize answers with ACP's first version, whatever version was asked for, and says what Link Harness does: it loads, lists and resumes sessions, and takes images and embedded resources in a prompt.

Sessions are conversations

A session is one of the agent's conversations, and its id is the conversation's thread: telegram:42, default. A connection prompts and cancels only the sessions it opened.

session/new

Opens the thread _meta.link.thread names, or a new one, acp: and a number, which is then the session's id.

session/load

Replays a conversation as the updates that streamed it, then opens it. A replay holds your messages, the agent's answers, its calls and their answers, and each command as you typed it. Summaries and thinking are the model's, and never replayed.

session/resume and session/list

session/resume opens a conversation without replaying it, and session/list lists the agent's conversations, kept any way.

session/prompt and session/cancel

session/prompt sends a message, answered as a turn, and session/cancel ends the session's running turn: its prompt answers cancelled.

Its commands

Once a session is open, the harness lists the commands that are on, in an available_commands_update. A command is still the text of a prompt (/chat:create), as it is on every connection: Commands.

Prompt it

A prompt's text blocks are its message. An image block, or an embedded resource with its contents in base64 (blob) or as text, is an image or a document of the message, taken as the agent's spec says ([connections] images and documents). A link to a resource, or audio, is refused, never dropped.

While its turn runs

The session is sent agent_message_chunk, each piece of the answer as it's written; tool_call, each call the model makes, its tool's name as its title and its arguments as rawInput; and tool_call_update, the call's end, completed or failed, with the tool's answer.

Its answer

The prompt answers end_turn once the turn ends, or cancelled when it was cancelled. A prompt sent while another turn of the session runs joins it, as the agent's spec says ([turns] busy): it then answers at once, end_turn with _meta.link.steered true, and the running turn's answer answers it too.

Steer, queue or interrupt

What a prompt sent while another turn of its session runs does is its own to say, in _meta.asp.busy, as the Agent State Protocol (ASP) has it: steer joins the answer under way, queue waits for its own turn, and interrupt ends the running turn and starts its own. Left out, the agent's spec says ([turns] busy).

{"sessionId": "telegram:42", "prompt": [{"type": "text", "text": "Skip the tests."}], "_meta": {"asp": {"busy": "interrupt"}}}

Which it takes

initialize says which it takes, in _meta.asp.steering: queue and interrupt always, and steer where its spec steers, since only then does a running turn read what joins it. One it doesn't take is refused.

What Link says of a message is in its _meta, under link. A _meta.link holding anything else, or a sender that isn't one, is refused.

{"sessionId": "telegram:42", "prompt": [{"type": "text", "text": "Find the note."}], "_meta": {"link": {"from": "person:telegram:42", "fromName": "Ada", "hops": 1, "tools": ["memory__search"]}}}

thread

On session/new, the conversation the session is, which it opens in place of a new one.

from

Who the message is from, when it isn't the owner: a schedule, a subagent, another agent or a person. A message from anyone but the owner never runs a command.

fromName

A person's name on their connection, beside a person only, words for the model and never who they are.

hops

How many messages led to it, each sent by an agent on its own, which the harness passes on to its tools.

tools

The tools the conversation may use from this message on, never more than it had: a later list narrows the conversation's tools.

Its conversations, over ASP

It answers the agent state of ASP for its conversations, and advertises them under initialize's _meta.asp.agentState. _asp/state/export gives each conversation as session/load replays it; _asp/state/import writes each one that came in under its thread's name, so session/list lists it and it goes on. Who the agent is, its skills and its memory are its spec's and Link's memory's, which Link carries itself: an import says so of each.

A conversation its thread already holds

A conversation that went on in another harness comes back to a thread that holds its start. The import adds only the turns after what the thread holds, when the owner's turns that came in open with the thread's, each holding its words. Otherwise the conversation parted, and goes on a thread beside it named for the harness it came from, such as link:main~hermes, which lnk agent chat list shows. One that names no harness goes on a new thread of its own.

Imported conversations

Session import keeps the exact exported updates beside native records for text, completed tool calls, images and supported resources. Historical tool calls never execute. Thinking without native signatures, audio and unsupported metadata remain archival: export preserves them, while the continuing conversation uses the content it can represent. The import response names this limit.

Two methods of Link's own sit beside ACP's: _link/taken, which it sends as soon as it has a message, and _link/recover, which asks for the answers a crash left.

_link/taken

A notification it sends a session as soon as it has a prompt's message, before the answer: a webhook's sender is told then.

_link/recover

A request for the answers of turns finished after a crash, or whose caller went before the answer: {"two_phase": true} gives each with its id, kept until {"done": ["<id>"]} says it was sent. Link's bridge asks as it starts and every half minute.

Ask it from a shell

lnk-link-harness ask <thread> asks the harness on this machine's port one message, as a bridge does, and prints its answer: the endpoint key on stdin's first line, the message after it. --from <sender> sends it from someone who isn't the owner. Why it works this way: Decisions.

printf '%s\nWhat did I say?' "$KEY" | lnk-link-harness ask telegram:42

Docs

  • Decisions: Why Link Harness speaks ACP the way it does