Interface
The commands a connection plugin, lnk-<connection>, answers for lnk agent, as link-connection-contract types them.
The contract
The contract is the link-connection-contract crate, connection.rs
and the modules beside it. A plugin's main is the crate's
(link_connection_contract::main, main_with_bridge, main_linked),
handed the connection's About, so a plugin from outside Link needs nothing of Link's but PLUGINS to plug in.
Connect and describe
Three commands every connection answers: one that makes the bot the agent's, one that says what the connection is, and one that says how a harness speaking it reaches its service.
lnk-<connection> connect --agent <name> [--taken <json>]
Asks for the connection's token, from its LNK_<CONNECTION>_... variables
or else on the terminal, its questions on stderr. It refuses a bot in
--taken ([{"id", "agent", "machine"?}], the bots other agents have,
here and running on boxes), and pairs whoever sends the bot its
one-time code. Read by lnk agent connect, which stops the agent
meanwhile, keeps the settings and starts it again.
What it prints
It prints link_connection_contract::Connected on stdout, to another
program only, since its settings hold the token and a terminal would
keep it:
{"settings", "bot": {"id", "name", "team"?}}. settings is the
connection's section of link_harness_contract::harness::Settings, and
bot.id what another agent's connect refuses: a Telegram bot's id, a
Slack or Discord bot's user id, the agent's Signal number.
lnk-<connection> describe --json
Prints link_connection_contract::Describe: {"title", "secret_fields"?, "linked", "bridged", "cli"?, "pushed"?, "held"?, "hosts"?, "disconnect_hint"?, "share"?}. Read by lnk agent connect,
which keeps it with the connection.
What each field says
title is its name as people write it, and secret_fields the fields
of its settings that are secret, which your secrets keep (as
AGENT_<AGENT>_<CONNECTION>_<FIELD>). linked says its account is linked
as a device (link, state), bridged that it carries messages
(bridge), and cli that it installs a program for a harness speaking
it.
How its messages come
pushed says its messages can be pushed over HTTPS (hook, unhook,
sift, take), and held that they come only on a connection hold
holds. hosts names the hosts a harness speaking it reaches,
disconnect_hint what lnk agent disconnect says stays the user's,
and share whether a chat of it can be shared.
lnk-<connection> rule --json
Prints link_connection_contract::Rule, or null when the harness
holds the connection's secret itself: {"field", "root", "host", "paths": [{"prefix", "rest"}], "header"?, "refuse"?, "methods"?, "public"?}. Read by lnk-agent with keys on the wire (lnk agent secret wire).
What the rule says
field is the field of the connection's settings holding the secret,
and root the field a harness reads the service's address from. The
rest is the proxy's rule for the secret
(link_sandbox_client::SecretRule): {secret} where the secret goes
in a path's prefix, else in header; rest name or path;
refuse paths compared without case; public the separator ending the
secret's part that isn't secret.
How the harness is handed it
The harness is handed a placeholder in field and its proxy's
secret_url in root, and the proxy the secret. A plugin that can't
answer keeps the agent from starting, so its secret never reaches the
harness.
Share a chat
A connection whose chats can be shared with the agent's owner's people answers one more command, which waits for the owner to share a chat.
lnk-<connection> share
A connection whose describe says share only. Reads
link_connection_contract::Sharing as one JSON line on stdin
({"settings", "code", "wait_secs"}, the connection's settings with
its token, and a one-time code). Run by the core with the agent
stopped, which then writes the chat into the agent's spec's
[access] message (link_agent_spec::with_message_access, the rest of
the file kept).
What it waits for
It waits up to wait_secs for a message in a group of the connection,
from a user it's paired with (allowed_users), that mentions the bot
with the code as a word of its own, in any case
(link_connection_contract::has_code, constant time). It reads no one
else's message, and takes the code once.
What it prints
It says in the chat that it's shared, and prints Shared ({"chat", "name"?}): the chat as a chat principal, chat:<connection>:<chat id>,
anything else refused, and its name for people.
What main holds it to
link_connection_contract::main reads the line, caps wait_secs at
SHARE_WAIT (10 minutes), stops the plugin's own wait when it's over
(exiting nonzero), and refuses a chat of another connection. It never
prints the code or the settings, not even for a line it can't read. A
plugin hands it its way of waiting (About::share), which also makes
describe say share.
A program in the harness's home
A connection whose describe says cli (lnk-signal) installs a
program a harness speaking it runs, and answers three commands for it.
lnk-<connection> cli install --home <dir> --json
Reads its section of the settings on stdin (Signal's {"account", "allowed_users"}), installs the program into that harness's home
<dir>, and prints {"program", "state"}: its path
(<dir>/.signal-cli/signal-cli), and what of the home is the agent's
account, ~/-relative (["~/.signal-cli/data"]).
How the harness runs it
Read by lnk-agent, which runs it in the harness's sandbox, reaching
the adapter's hosts, before configure, and hands the adapter the path
in the section ("signal": {..., "program"}): an adapter runs that
program, and knows no layout of the plugin's.
lnk-<connection> cli state --json
Prints {"state"}, as install prints it, with nothing installed.
Read by lnk agent move, send, receive, backup and restore,
which carry it with the harness's own state (lnk-<name> state) for
each such connection the harness speaks.
lnk-<connection> cli link --home <dir> [--again]
Links the account in <dir> with that program, its QR code on the
terminal. It exits 0 once linked (--again: anew), and 3 when it isn't
and there's no terminal. Read by lnk-agent in place of the adapter's
link for such a connection, in the harness's sandbox.
A linked account
A connection whose describe says linked (lnk-signal) links its
account as a device, and answers two commands for it.
lnk-<connection> link [--again]
Reads the same Bridge as bridge does on stdin, and links the
connection's account as a device into its state, from a QR code on
stdout. It exits 0 at once when it's linked already (--again: links
it anew), and 3 when it isn't and stdout is no terminal. Read by
lnk-agent (lnk agent connect, lnk agent start), for a harness
with an endpoint.
lnk-<connection> state
Prints link_harness_contract::harness::State as JSON: what in its
state folder is the link, ~/-relative to that folder (Signal's
account), never a program it installed. Read by lnk-agent
(lnk agent state-send), which carries those paths as
connections/<connection>/..., named in the move's manifest (connections).
Carry messages
For a harness that speaks no connection itself, the connection carries its messages to the harness and its answers back, and sends what nobody asked for, such as a schedule's answer.
lnk-<connection> bridge
A connection whose describe says bridged only. Reads
link_connection_contract::Bridge as one JSON line on stdin, then
carries messages until stdin closes: each direct message from an
allowed user goes to the harness's endpoint, and the answer comes
back. Read by lnk-agent (lnk agent bridge, and lnk agent start --foreground), for a harness with an endpoint.
The line it reads
Bridge is {"agent", "agent_id"?, "identity"?, "endpoint", "key", "proof"?, "parts"?, "model", "settings", "state", "spec"?, "shared"?}. identity is the agent's ({"agent", "owner", "teams"?}), its ID sent as Link-Agent-Id, and agent_id too, for a
plugin from before identity. settings is the connection's section,
and state a folder for this connection alone, mode 700, which no
harness reaches.
The chats it's shared with
shared holds the ids of the connection's chats the agent is shared
with (its spec's [access] message, chat:<connection>:<id>), sent only
to a harness whose contract says from, and none when left out. No
bridge of Link's reads it: each answers its owner alone.
The agent's opinions for it
spec is the agent's opinions for the connection, {"working"?, "stream"?, "activity"?, "pace"?, "steered"?}, its spec's
[connection.<name>] over [connections]: a reaction while it answers,
whether the answer is shown as it's written, with each tool it calls
above it as it calls it (activity, from its ACP tool_calls), and
how often. Left out, the bridge's own way.
How often it shows an answer
An answer is shown again never more often than the platform documents a bot may write: Telegram once a second in a chat and every 3 seconds in a group, Slack every 1.2 seconds. Discord documents none, so the bridge follows the rate limits it's answered with.
The endpoint
A message goes to the harness's endpoint
(link_connection_contract::ask, which asks for the proof when proof
says the endpoint proves it's the harness first):
http://127.0.0.1:<port>/..., or over a Unix socket
(link_connection_contract::UNIX_ENDPOINT), unix:<path> for HTTP on
the socket at <path>, or unix:<path>:<port>, a sandbox's in.sock
told <port> inside first, two bytes, as its proxy would.
Who the socket may be
The proof, the message and a recovery all go that way. The socket is reached only as itself, never through a link, and must be owned by its folder's owner and listened on by that user, else it's refused.
Photos and documents
With parts (its harness's contract says parts; left out, no), a
message's photos and documents go with its text
(link_connection_contract::holding and ask_holding). Each whose
type is taken (link_connection_contract::MEDIA: PNG, JPEG, GIF or
WebP, PDF or text, and a voice note's Ogg, MP3, MP4 audio, AAC, WAV or
WebM, for a harness on ACP alone) and within PART_MAX (10 MiB) is
downloaded from its platform, at most PARTS_MAX (8).
How the parts are sent
Each part is checked to be what its type says, and sent as OpenAI's
image_url and file content parts, or on ACP as image, audio and
embedded resource blocks. Each one left out is named in a line of its
text, and why, and a message left with nothing is answered by the
bridge.
Without parts
Without parts, none is downloaded, and a message holding only them is
answered "I can read text only, for now."
lnk-<connection> send
A connection that can send a message nobody asked for (lnk-telegram,
lnk-slack, lnk-discord, lnk-signal). Reads
link_connection_contract::Sending as one JSON line on stdin
({"bridge", "chat", "text"}) and sends text to the chat. Read by
the bridge's way in (below), for an answer to a schedule or a subagent.
The chat it names
bridge is the same Bridge as above, and chat the chat as its
thread names it after the connection: telegram:42's 42, a forum
topic's -1001234.7, or a Slack thread's C0123ABC.1700000000.000200,
sent into that topic or thread.
Where it may send
It sends only to a chat of the owner's alone: on Telegram and Signal an
allowed user's own chat, on Slack the owner's direct conversation with
the app (dm_channel), on Discord the owner's direct messages
(dm_channel). An app or bot connected before Link kept those sends
nowhere until it's connected again.
A group it may send to
It sends to a group too, one an allowed user talked to the agent in
(any of its topics or threads), which the bridge keeps in groups.json
of its state (link_connection_contract::remember_group), while the
agent's spec answers in groups: never with groups = "never".
When it can't
It exits nonzero when it can't send, and 11 when the connection refused
the bot's token (link_connection_contract::TOKEN_REFUSED), which it
will until the token changes.
Through a linked connection's bridge
A linked connection holds one connection, its running bridge's, so
lnk-signal send hands the message to it at send.sock in state
(600): a bridge's call whose action is {"chat", "text"}, proved with
the Bridge's identity and key, answered {} or {"error"}
(link_connection_contract::send_through, take_sends). The bridge
takes only its own agent's, and send fails when it isn't running.
Messages pushed to a machine
A machine that packs agents has a connection's messages pushed to it
over HTTPS, or held for it, instead of a bridge polling them. Five
commands do it, none of them run by lnk itself.
lnk-<connection> hook
A connection whose describe says pushed (lnk-telegram,
lnk-slack). Reads link_connection_contract::pushed::Hooking as one
JSON line on stdin ({"settings", "url", "secret"}, the connection's
settings with its token) and points the connection's pushes at url,
each carrying secret. Runs outside every sandbox, where the token is.
Each connection's secret
Telegram's secret is its secret_token. Slack signs with its own
signing secret, in settings, which lnk agent connect slack doesn't
keep: the caller adds signing_secret, and config_token to switch the
app itself, used and never kept. Without the signing secret, Slack's
hook fails and switches nothing.
What it prints
It prints Hooked ({"settings", "steps"?}): settings, what sift
is handed, none of it secret (who may talk to the agent, the bot's id),
and steps, what a person does on the connection's own pages when the
plugin couldn't make the change itself.
Its exits
It exits 13 when the connection refused the webhook
(link_connection_contract::WEBHOOK_REFUSED: it answered and said no to
the address or its secret, as Telegram's setWebhook does), 11 when it
refused the bot's token (TOKEN_REFUSED), and nonzero for any other
failure. Slack's never refuses, falling back to steps when it can't
switch the app.
lnk-<connection> unhook
The same connections. Reads Unhooking ({"settings"}) and turns the
pushes off, so bridge polls or holds its socket again.
lnk-<connection> sift
A connection whose describe says pushed or held. Reads Sifting
JSON lines until stdin closes, and writes a Verdict line for each, in
order. It holds no token and calls nothing.
The lines it reads
A line is {"settings", "secret", "previous"?, "headers", "body", "shared"?}, the headers' names in lowercase, or {"settings", "say": {"chat", "text"}}. previous is the secret before secret while it
rolls, taken beside it and sent only then (Sifting::secrets, for
link_secret_value::inbound).
The chats it's shared with
shared holds the chats on the connection the agent is shared with, by
the connection's ids, as its spec's [access] message says when the
push comes, never kept from hook, and sent only when there are some.
Drop
{"drop": {"status"}}: a wrong secret or signature, 401; a stranger's
message, 200, so the connection doesn't push it again; a line it can't
read, 400.
Answer
{"answer": {"status", "content_type", "body"}}: the connection's
check of the address, or a say the connection takes in a push's own
response, as Telegram's sendMessage.
Take
{"take": {"thread", "chat", "dedupe", "from"?}}, a message for the
agent: its Link-Thread, its chat, and the connection's id for the
push, the same on a retry. For a say, the machine sends it with
send.
Who a message is from
from is person:<connection>:<user id>, set when the message is
someone's in a chat the agent is shared with, not the owner's. Only a
connection whose describe says share sets it, and only for a chat
shared names; a machine that packs agents takes it only in a chat
shared names.
The webhook plugin's
lnk-webhook speaks sift alone, run by an agent's bridge for its
webhooks (lnk agent webhook), which hands it a webhook's {"name", "from"} as settings (from link or github) and its secrets.
lnk-<connection> take
The same connections as sift. Reads Taking ({"bridge", "update"}: a Bridge as bridge reads it, and the update pushed, its
body as JSON or what hold gave) and answers it as bridge answers a
message it polls, its endpoint http:// or unix:<socket>. It ends
once the answer is sent.
Its exits
It exits 11 when the connection refused the bot's token
(TOKEN_REFUSED), and 12 when it refused the answer itself
(link_connection_contract::REPLY_REFUSED): the connection answered the
send and said no, as it does for a chat gone or a bot blocked. A
connection says the same of a request of the plugin's own it can't
take, so a 12 isn't always the user's doing; any other failure is
nonzero.
lnk-<connection> hold
A connection whose describe says held (lnk-discord), whose
messages come only on a connection held open. Reads HoldLines until
stdin closes, {"hold": {"key", "settings"}} (hold that bot's
connection as key) or {"drop": "<key>"}. One process holds many
bots, resuming a dropped connection where the connection allows. It
answers and sends nothing.
What it writes
It writes a Held line for each message ({"key", "update"}) and for
each connection up or down ({"key", "state": "up" | "down" | "refused"}), refused being down because the connection refused the
bot's token. A reader takes a state it doesn't know as down, never
up.
The way in
For a harness whose contract says from, lnk agent bridge listens at
~/.lnk/agents/<agent>/way-in.sock (600, link_connection_contract::way_in)
for messages from others. Each is a bridge's call whose action is
link_connection_contract::Firing, proved as the agent whose scheduler
or run hands it (link_connection_contract::fire).
A firing
Firing is {"thread", "from", "text", "answer", "hops", "tools"?},
answer one of "chat", {"thread": "<name>"},
{"connection": "<service>"} or "nowhere". A connection selects the
owner's direct chat on that connected service; cli selects the CLI
inbox, delivered to an active or next opened CLI chat. The
bridge answers with one line, link_connection_contract::Fired
({"answer", "error"?}), once the answer went.
Whose firings are taken
Its own agent's are taken whoever they're from. Another agent's are
taken only from itself (from agent:<its ID>), a teammate, into the
receiver's conversation with it (agent-<its ID>) or a team's they
share (team-<team ID>, link_connection_contract::teammates_thread,
teams_thread), its answer "nowhere". Any other is refused before
the harness sees it.
A teammate's
A message from another agent (agent:<id>), whoever hands it, is
refused unless the two share a team, as the teams' files say when it
comes.
A stranger's
A stranger's message (link_plugin::access::Standing::Stranger, a
sender neither the owner's own nor let in, webhook:<name>) is given
only the tools its spec's [tools] strangers names: its tools
narrowed to them, or set to them when it has none.
What a stranger's is refused for
A stranger's message is refused for a harness whose contract doesn't
say tools, for a thread other than its sender (webhook:<name>),
and for an answer other than "nowhere".
What it sends the harness
It sends each firing to the harness's endpoint with Link-From and
Link-Hops, and Link-Tools when it has tools, refused unsent for a
harness whose contract doesn't say tools.
Where the answer goes
The answer goes where answer says: to the chat its thread is
(lnk-<connection> send), to another conversation as a message from the
same sender, one hop further (a subagent's answer, to its parent's), or
nowhere.