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.

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.

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.