The Endpoint
How Link carries the messages of a harness that speaks no connection itself: its adapter names its endpoint, where it speaks ACP, the Agent Client Protocol, and each message from its connections is a prompt there.
lnk-<name> endpoint
For a harness that speaks no connection itself: a JSON string, the
address of its ACP on this machine's loopback
("http://127.0.0.1:18800/acp",
link_connection_contract::acp::ACP_PATH), at one of its
Launch.ports, LNK_PORT taken into account. Read by lnk-agent
(lnk agent start, connect, use, status). A harness that speaks
ACP over stdio is served there by the hub.
What Link carries for it
Link then carries the connections whose plugins carry messages
(describe's bridged: all four of Link's) for it. An adapter without
the command speaks its own connections.
Each message's WebSocket
Each message is a WebSocket opened at the endpoint, upgraded on the
connection that proved it's the harness, with Authorization: Bearer <endpoint_key>, Link-Agent: <agent> (main) and, when it has one,
Link-Agent-Id: <its ID>, one JSON-RPC message to a text frame:
initialize, then session/resume of the session its thread names,
then session/prompt.
Its thread
The session's ID: <connection>:<chat id>: telegram:123456789, a forum
topic's telegram:<group>.<topic id>, slack:D0123ABCDEF, a channel's
Slack thread slack:<channel>.<thread ts>, discord:<channel id>,
signal:15551234567, and link:<name> from lnk agent chat send, Link's
own connection, sent the same way; default for none.
The prompt
Its text is a text block, and its images, voice notes and documents
image blocks, audio blocks and embedded resources in base64, the
message alone, never the thread's history. The harness tells
_link/taken as soon as it has the message, before its answer.
Its answer
The answer streams as agent_message_chunks, each call it makes as a
tool_call titled with the tool's name (memory__search) and its end
as a tool_call_update, which the bridge shows where the agent's spec
says [connections] activity. The prompt answers once the turn ends. An
answer is read up to 16 MiB; past that the owner is told the agent
failed.
_meta.link
What Link says beside a prompt is in its _meta.link
(link_connection_contract::acp::Said): from, fromName, hops and
tools, below. One that isn't Link's, or names what Link refuses, is
refused before the model sees it.
Proving it's the harness
With the contract's proof, each message goes on a connection of its
own that first proves it's the harness; a connection that doesn't is
sent neither the key nor the message.
GET /_link/proof
At the endpoint's host and port, with Link-Challenge: <32 to 128 hex characters> and Link-Agent, answered 200 with Link-Proof: the
HMAC-SHA256 of "link endpoint proof", a zero byte and the challenge,
keyed with endpoint_key, in hex
(link_harness_contract::harness::endpoint_proof).
TCP_NODELAY
An endpoint that answers the proof sets TCP_NODELAY on the connections
it accepts: each message is a new connection, and without it a short
answer waits about 40 ms for the connection's delayed ACK. The
conformance kit reads it from the socket where the system says.
Without proof
The key and the message go to whatever answers on the port, whether or not it's the harness: nothing proves it is.
Answering for many agents
With the contract's agent, the endpoint answers a connection as the
agent Link-Agent names, with that agent's key, so one process could
answer for many agents. Without agent, every one is its own agent's.
By name
One naming no agent is its own agent's (LNK_AGENT), and one naming an
agent it doesn't run is refused (404) before the model sees it.
By ID
Names are unique only to an owner: an endpoint answering for more than
one owner's agents finds the agent by Link-Agent-Id, refuses one whose
ID and name disagree (404), and one naming by name alone an agent two
owners share (409).
Who a message is from
With the contract's from, a message from someone who isn't the owner
says who in _meta.link's from, fromName and hops. The harness
records who it's from, tells the model who's speaking (never as the
owner), and passes the hops on to its tools; Link refuses a from
naming no such sender before it's sent.
from
The sender (link_connection_contract::is_from,
link_plugin::access::person): schedule:<id>, task:<id>,
agent:<id>, run:<id>, a background command of run's telling how
it ended, person:<connection>:<user id>, or webhook:<name>.
A person or a webhook
A person: is someone in a chat the agent is shared with, as the
connection names its users (person:slack:T0ACME/U0ALICE,
person:telegram:42). A webhook: is a post to one of the agent's
webhooks, a stranger's.
fromName and hops
A person:'s name on their connection, when it gives one: plain UTF-8,
at most 64 characters, no control character
(link_connection_contract::is_person_name). It's words for the model,
never who they are. hops is how many messages, each sent by an agent
on its own, led to it.
$LNK_SCHEDULER
Its harness's environment then holds $LNK_SCHEDULER
(link_memory_contract::SCHEDULER_ENV), the agent's scheduler, an MCP
server over HTTP through its proxy, beside $LNK_MEMORY. A call to it
carries _meta {"link.local/thread", "link.local/hops"}, the
conversation it's made in and the hops of the message being answered.
A conversation's tools
With the contract's tools, a message may carry _meta.link's tools
(link_connection_contract::tools_of): the tools its conversation may
use from then on.
The list
The names the harness offers them by (memory__search), at most 256,
each 1 to 128 letters, digits, _, - and ., or empty for none.
One that isn't a list of names is refused before the model sees it.
Kept with the conversation
The harness keeps the list with the conversation and offers it only those of its tools from then on. A later list narrows it to the tools in both, never widens it, and a message without one keeps it.
link.local/tools
A call to its tools then carries _meta "link.local/tools" too, the
names of the tools the conversation has this turn, which the scheduler
reads so a subagent gets its parent's tools or fewer.
link.local/spec
Link Harness's calls also carry _meta "link.local/spec", the called
tool's own table of the agent's spec ([tool.<name>] over [tools],
as JSON), for a tool with opinions of its own, such as Link Web's
fetch. Link sends it only with a message from others whose Firing
has tools (a subagent's task).
link.local/model
Link Harness's calls also carry _meta "link.local/model", the model
the conversation runs on, so a subagent the scheduler starts without a
model of its own runs on its parent's.
A conversation's model
A message may carry _meta.link's model, a model's name or an alias
of the spec's [model.aliases], so its conversation runs on it from
then on: a subagent's, started on one. It's 1 to 128 letters, digits and
-._:/@, else refused. Link Harness keeps it as a /model:set event of
the conversation, from the message's sender.
A tool call's activity
Link Harness marks each tool call it streams with _meta.link's
activity, true or false, where its conversation chose with
/chat:verbose. The bridge then shows the call, or doesn't, in place of
its spec's [connections] activity. A call without it is shown as the spec
says.
A message that joins a running turn
A message that joins a turn already running is answered at once, its
prompt's answer saying _meta.link.steered true
(connection::ask_for's Asked::Steered): the answer of the prompt that
started that turn answers it too.
How the bridge marks it
The bridge marks the message as its spec's steered says, or not at
all; the message gets no answer of its own, since the turn's one
answer answers both.
From lnk agent chat send
A message lnk agent chat send sends that joins a running turn is answered
by that turn's answer, which, its caller gone, the harness keeps for it:
the first kept in the thread after the message was sent, never one kept
before it.
Answers after a crash
With the contract's recover, the request _link/recover
(link_connection_contract::acp::RECOVER) answers {"answers": [{"thread", "text"}]}, on a connection opened as a message's is.
What it hands over
Each answer of a turn it finished after a crash, or whose caller went
before it was told it (the bridge stopped), which nobody waited for,
handed over once (connection::recovered). The harness finishes the
crashed turns in a task of its own, so a bridge that stops waiting
leaves none half done.
Two phases
Its params empty or {}, that is all. With {"two_phase": true}, each
answer also has an "id" (an opaque string) and is kept, handed over
again at each ask, until a request with {"done": ["<id>", ...]}, sent
once it went to the chat, lets those go. That one answers {"answers": []}, and an id that isn't one is passed over.
When the bridge asks
The agent's bridge asks as it starts, again each time the harness
answers after it couldn't be reached, and every 30 seconds while it
runs, and sends each to the chat its thread is, but for a link:<name>
thread's.
lnk agent chat send's answers
The bridge leaves a link:<name> thread's answers kept, and lnk agent chat asks for its thread's itself, at its start and before each
message, and shows them.