/ documentation

Custom agents

Put your own agent behind the ironwire app. One JSON file on the machine describes it; the agent speaks either ACP over stdio or the relay contract over loopback HTTP. This page is the full reference for both.

Everything here happens on an agent machine: one created from the wireling image (create <name> --image=wireling, or any machine created from the app except a Plain Ubuntu one). A machine on the default image has no daemon, no /etc/wireling and nothing for the app to talk to. For what the app itself does, see the app & agents.

What ironwire gives an agent

Everything a machine offers someone building an agent on it, one line each, with where it is documented. Use these rather than bringing your own: there is no need for a tunnel, a notification service, a scheduler or a chat UI of your own.

WhatWhere
Waking upOn a timer: wireling schedule add "daily 08:00" <task>, in the owner's timezone, surviving restarts.The machine talks back
Now, from any script, job or service on the machine: wireling run <task>.Reaching the owner
On email: a machine can have its own address (the owner switches it on); a script checks for new mail and calls wireling run.Inbound email
On a webhook from outside: publish a port and point the service at it.Publishing extra ports
Reaching the ownerAs an agent in the app, with its own chats: an ACP program or a relay service.Two shapes
Speaking first, with no turn open: a note.Notes
Saying who is speaking: --from <name>, shown in the app.Reaching the owner
The phone rings for every finished reply and note, with nothing for the agent to do.What every custom agent gets
ChatsList the machine's chats with wireling chats; make one for a job with wireling chat new.Reaching the owner
Between machinesEvery machine reaches its siblings by name, <name>.internal, on any port.Machine-to-machine networking
One drive, the same files on every machine that attaches it.Shared drive
A machine can create, start, stop and run commands on its siblings, when the owner allows it.Fleet access
Tools and configThe owner's connected services (GitHub, Linear and others) with no key on the machine.Connections
The same services and the fleet commands as MCP tools: ironwire mcp.Two shapes (the ironwire tools row)
Keys and settings as environment variables, per account or per machine.Environment variables
LimitsOne turn at a time per machine, across all its chats: agents in the app on one machine take turns.What every custom agent gets
note, run and chat.new are rate limited: 30 per five minutes per machine, burst 10.Reaching the owner
A relay has 10 seconds to accept each message.Accepting it

Two shapes: ACP or relay

The machine runs a small daemon that sits between the app and the agent. It can drive an agent in two ways, and the question that picks one is who owns the conversation.

ACPRelay
The agent isA program the machine starts for a chat and talks to on stdin/stdout.A service already running on the machine, listening on a loopback HTTP port.
Owns the conversationThe machine. It starts, stops and restarts the program, and restores its session.The agent. It keeps its own memory and state; the app is one of its surfaces.
WireThe Agent Client Protocol: JSON-RPC 2.0, one message per line, over stdio.One HTTP POST per message in; the reply goes back as one-line JSON requests on a unix socket.
Process lifetimeManaged for you. Expect to be killed and started again.Yours: a systemd unit or anything else that keeps it up.
Model pickerYes, from the manifest or advertised by the agent.No. The picker shows Default only; the agent chooses its model.
Slash commandsAdvertised by the agent over ACP.Listed in the manifest; the text reaches the agent untouched.
Speaking firstOnly through wireling note.Natural: a note, at any time, with no turn open.
Streaming text and tool cardsYes.Yes.
Images from the appYes, if the agent advertises image prompts.Yes, always delivered.
Tool permission promptsAnswered automatically with allow.The agent's own business.
ironwire tools (connections, fleet)Offered to every session as an MCP server.Configure /opt/ironwire/bin/ironwire mcp in the agent yourself.

Choose ACP for a coding agent or any engine that answers a prompt and holds nothing the machine cannot restart: you get the model picker and process handling for free, and the same binary works in every other ACP client (Zed, JetBrains). Choose relay for an assistant that lives whether or not the app is open: one with its own memory, scheduled jobs or other messaging surfaces, or a thin service you would rather write as an HTTP handler than as an ACP server. Relay is the smaller contract; ACP is the richer integration.

In the app the two are indistinguishable: same picker, same chats, same notifications. The label comes from the manifest and nothing marks which shape is behind it.

The manifest

One JSON file per agent in /etc/wireling/agents.d/. The filename is yours; it must end in .json. The machine ships one such file already, for pi, which is the smallest complete example:

root@scout:~# cat /etc/wireling/agents.d/pi.json
{
  "kind": "pi",
  "label": "Pi",
  "bin": "/usr/local/bin/pi-acp"
}

When it is read

The folder is watched. A new file joins the app's agent picker within a second or two, with no restart of anything. A file that is already loaded is not re-read: editing or removing it takes effect at the next agent restart (chat settings → Agent session → Restart agent). Files load in filename order, and two files naming the same kind keep the first.

Fields common to both shapes

FieldRequiredRules
kindyesThe agent's identifier, recorded in every chat that uses it. Lowercase, 2–32 characters: a letter, then letters, digits or dashes. claude and codex are reserved. Do not rename it once chats exist: a chat whose kind is no longer installed cannot run.
labelyesDisplay name in the picker, 1–32 characters.
bin or relayexactly oneWhich shape this is. Both, or neither, rejects the file.

ACP fields

FieldRequiredRules
binyesAbsolute path to the executable that speaks ACP on stdin/stdout.
argsnoArguments to pass: an array of non-empty strings.
modelsnoChoices for the chat's model picker: a non-empty array of {"id", "label"}; label is optional and defaults to the id. The picker always offers Default as well. Without models it offers only Default.
modelEnvnoName of the environment variable (capitals, digits, underscores) that receives the chosen model id when the agent starts. Needs models. It is not set when the chat is on Default.
{
  "kind": "myagent",
  "label": "My agent",
  "bin": "/usr/local/bin/myagent-acp",
  "args": ["--acp"],
  "models": [{"id": "fast"}, {"id": "deep", "label": "Deep"}],
  "modelEnv": "MYAGENT_MODEL"
}

Relay fields

FieldRequiredRules
relayyesThe address each message is POSTed to. Must be http:// on 127.0.0.1, localhost or [::1], any port and path. Anything else, including https:// and any other host, rejects the file.
contractnoThe relay contract version your service speaks, an integer. Without it, 1. Every message you receive carries it as v. A version the machine does not speak rejects the file. See compatibility.
commandsnoSlash commands for the composer's / menu: an array of {"name", "description", "argHint"}. name has no leading slash: a lowercase letter, then up to 31 lowercase letters, digits, dashes or underscores. description is 1–80 characters. argHint is optional.
authnoHow the machine tells whether the agent is signed in. See sign-in from the app. Without it the agent always shows as Ready.
setupnoThe sign-in choices the app offers when auth says it is not. Same section.
{
  "kind": "butler",
  "label": "Butler",
  "relay": "http://127.0.0.1:8700/inbound",
  "commands": [
    {"name": "status", "description": "What the butler is working on"},
    {"name": "remind", "description": "Set a reminder", "argHint": "<when> <what>"}
  ]
}

Strictness

The file is strict, so that a typo never silently does nothing. Each of these rejects the file: an unknown field at any level; a relative bin; both or neither of bin and relay; a reserved or malformed kind; modelEnv without models; an empty models array; args, models or modelEnv together with relay; contract, commands, auth or setup together with bin; a contract that is not an integer, or names a version the machine does not speak (relay contract 2 is not supported by this machine (supports: 1)).

A rejected file is skipped, not fatal: the built-ins and every valid file still load. The reason is in the machine's log, and that log is the first place to look when an agent does not appear:

root@scout:~# journalctl -u wireling-daemon | grep 'agent manifest'
agent manifest /etc/wireling/agents.d/butler.json skipped: "relay" must be a loopback http URL

What every custom agent gets

Whichever shape you pick, the machine handles the chat around your agent:

One turn at a timeA machine runs one turn at a time across all of its chats. Messages sent meanwhile queue and run in order, so your agent never has two turns open.
The working directoryEach chat has one directory, fixed at its first turn (default /root/workspace). Your agent is told which.
Attached filesA file attached in the app is written into the chat's directory before the turn, and the message text ends with one line per file: Attached: <name>. Read it from the working directory like any other file.
ImagesUp to eight per message, delivered inline as base64.
Storage and pushEvery turn is written to the machine's disk and a finished reply rings the owner's phone. Your agent does nothing for either.
FailuresA failed turn shows the owner one fixed line, the agent hit a problem finishing this turn. Try again. The real reason goes to the machine's log only, never to the phone.
Reachwireling note, wireling run, wireling schedule, wireling chats and wireling chat new work from inside your agent as from any script (reaching the owner).

Signing in is yours. A custom agent is assumed to be set up: the app shows it as Ready and has no Connect button for it. Give it keys as environment variables or run its own login over SSH (inside tmux if it is interactive). An agent that is not configured simply fails its turn. The one exception is a relay that declares auth and setup.

note A failure whose text looks like a credential problem (it contains 401, invalid api key, not logged in, authentication error or an expired or revoked OAuth token) ends the turn as signed out instead of as a generic error. For an ACP agent that text is matched in its stderr; for a relay, in the turn.error message. Word your errors accordingly.

Reaching the owner: wireling

Anything on the machine, your agent included, reaches the owner's chats with the wireling command, or by writing the same requests to the reach socket /run/wireling-reach.sock directly (one JSON object per line, as in replying). Both reach this machine's chats and nothing else.

CommandSocket opWhat it does
wireling chatschatsLists the machine's chats: {"ok": true, "chats": [{"id", "title", "agent", "created_at", "default"}]}. default marks the chat that requests without a chat go to. Use the ids with --chat.
wireling chat new <title> [--agent K]chat.new {title, agent?}Creates a chat and replies with its chat_id and agent. It appears in the app like any other; the owner can rename, delete or move it. Without --agent it runs the machine's current agent, or else one that is signed in. The title is 1–80 characters on one line. An agent the machine does not have is refused: no such agent: <kind>.
wireling note [--chat ID] [--from NAME] <text>notePosts a message, with no turn. See notes.
wireling run [--chat ID] [--agent K] [--from NAME] <task>run {task, chat?, agent?, from?}Starts a turn now, as if the owner had sent task; queued behind a turn already running.
wireling schedule add <spec> [--chat ID] [--agent K] [--from NAME] <task>schedule.add {spec, task, chat?, agent?, from?}Runs task on a timer: daily HH:MM, weekly <dow> HH:MM or at <ISO-8601>, in the owner's timezone. schedule list and schedule cancel <id> (schedule.list, schedule.cancel) manage them.

from says who is speaking. With --from researcher the app shows a note as from researcher and a turn as started by researcher, and a schedule carries the name onto every turn it starts. It is a display name, 1–32 characters on one line, and grants nothing: it never changes which chat is used or what may be done. Without it, notes and turns are labelled as before (from a script, started by your schedule).

Which chat. Inside a turn, wireling uses the chat the turn belongs to (IRONWIRE_CHAT_ID). Anywhere else, a cron job or a service, pass --chat; without one, a note or a run goes to the machine's default chat, and a schedule gets a new chat of its own. An unknown id is refused: no such chat: <id>.

Limits. note, run and chat.new share one rate limit, 30 per five minutes per machine with a burst of 10; over it the reply is {"error": "rate limited"}. chats is not limited.

ACP agents

The machine is an ACP client, in the role an editor normally plays, and your program is the ACP agent. Nothing ironwire-specific is required of it. If your agent does not speak ACP, put an adapter in front of it, as pi does with pi-acp. The protocol itself is documented at agentclientprotocol.com; what follows is how this particular client behaves.

How the program is run

Commandbin with args, as root, with stdin, stdout and stderr piped.
Working directoryThe chat's directory. The same path is passed as cwd when the session opens.
EnvironmentThe machine's environment: your account and machine variables, the injected IRONWIRE_* variables, and a PATH that finds the ironwire and wireling commands. Plus IRONWIRE_CHAT_ID, the chat this process serves (it is what makes wireling note land in the right chat), and your modelEnv variable when a model is chosen.
stdoutProtocol only: one JSON-RPC message per line. A line that is not JSON is skipped, but keep logging off stdout.
stderrCopied to the machine's log, prefixed with your kind. This is where your diagnostics go.

Expect to be restarted. The machine keeps one agent process alive at a time: the one for the chat that ran last. It is started by a chat's turn and reused for that chat's following turns. It is killed, with SIGKILL and no warning, when a different chat takes a turn, when the chat's model changes, after a turn is stopped or fails, and on an agent restart. Keep nothing in memory that you cannot rebuild, and implement session loading (below) if the agent should remember a chat.

The conversation

StepWhat the machine sendsWhat it needs back
initializeProtocol version 1. Client capabilities: no file-system methods, no terminal. Your agent reads and writes files and runs commands itself; it cannot ask the client to.protocolVersion and agentCapabilities. Two capabilities are read: loadSession and promptCapabilities.image.
session/newcwd and mcpServers, holding one stdio server named ironwire.sessionId. It is stored with the chat.
session/loadInstead of session/new, when the chat already has a session id and you advertised loadSession. Same cwd and mcpServers.Success. History you replay as updates during the load is discarded, since the chat already has it.
session/promptOne per turn: the image blocks (if you take images), then one text block.Stream session/update notifications, then resolve with a stopReason.
session/cancelWhen the owner presses Stop.Resolve the prompt with cancelled promptly. The process is killed about a second later either way.

Session loading. If the chat has a session id but you did not advertise loadSession, or the load fails, the machine opens a new session and tells the owner once per chat: this agent could not restore its memory of this chat, so it is continuing fresh. Because the process is restarted often, an agent without loadSession forgets a chat whenever the owner visits another one.

The ironwire tools. The ironwire MCP server gives the agent the owner's connections and the fleet commands, authenticated as this machine. If your agent refuses a session that carries MCP servers, the machine retries once with none and the chat runs without those tools; the log names the agent and its reason.

How a turn ends. A stopReason of refusal ends the turn as a failure. Any other stop reason ends it normally. The process exiting, or the connection breaking, before the prompt resolves is a failure.

Updates the app shows

sessionUpdateWhat happens
agent_message_chunkText content is streamed into the reply. Non-text content is ignored.
agent_thought_chunkTreated the same as message text: it appears in the reply. Do not send thoughts you would not want shown.
tool_call, tool_call_updateOne tool card per toolCallId. The card appears as soon as the call has a non-empty rawInput, or when it finishes, whichever comes first. A status of completed or failed fills in the result, taken from rawOutput, or else from the text in content.
available_commands_updateReplaces the composer's / menu: each command's name, description and input hint. Honoured at any time, including right after the session opens.
anything elseIgnored (plans, mode changes, user-message echoes). Updates that arrive outside a turn are dropped.

Permission requests (session/request_permission) are never shown to the owner. The machine is the sandbox, so the client answers at once, choosing an allow_always option if there is one, otherwise allow_once, otherwise the first option offered. During a stop it answers cancelled.

Models

Two ways to offer a model choice, and they combine:

In the manifestmodels fills the picker and modelEnv delivers the chosen id as an environment variable when the process starts.
Over ACPIf the session response carries a config option with id model and type select, its options replace the manifest list, and the chosen model is set with session/set_config_option right after the session opens.

A model change in chat settings applies from the chat's next turn: the process is started again with the new choice. A chat on Default gets neither the variable nor the config call.

Images

If you advertise promptCapabilities.image, each image arrives as a prompt block {"type": "image", "mimeType", "data"} with base64 data, ahead of the text block. If you do not, images are dropped and only the text is sent; the log records how many were ignored.

A complete ACP agent

This is a whole agent in Python with no dependencies. It answers every message with an echo. For a real agent use an ACP SDK, which also handles cancellation while a prompt is running.

#!/usr/bin/env python3
import json, sys, uuid

def send(msg):
    sys.stdout.write(json.dumps({"jsonrpc": "2.0", **msg}) + "\n")
    sys.stdout.flush()

for line in sys.stdin:
    msg = json.loads(line)
    method, rid, params = msg.get("method"), msg.get("id"), msg.get("params", {})
    if method == "initialize":
        send({"id": rid, "result": {"protocolVersion": 1, "agentCapabilities": {}}})
    elif method == "session/new":
        send({"id": rid, "result": {"sessionId": uuid.uuid4().hex}})
    elif method == "session/prompt":
        text = "".join(b["text"] for b in params["prompt"] if b["type"] == "text")
        send({"method": "session/update", "params": {
            "sessionId": params["sessionId"],
            "update": {"sessionUpdate": "agent_message_chunk",
                       "content": {"type": "text", "text": "You said: " + text}},
        }})
        send({"id": rid, "result": {"stopReason": "end_turn"}})
    elif rid is not None:
        send({"id": rid, "error": {"code": -32601, "message": "method not found"}})

ssh

root@scout:~# install -m 755 echo-acp.py /usr/local/bin/echo-acp
root@scout:~# cat > /etc/wireling/agents.d/echo.json <<'EOF'
{ "kind": "echo", "label": "Echo", "bin": "/usr/local/bin/echo-acp" }
EOF

app

Open the machine, or tap + for a new chat: Echo is in the agent picker as Ready. Send it a message and it answers You said: ….

Relay agents

A relay agent is a service on the machine. The machine hands each message to it with an HTTP POST, and the service sends the reply back by writing to the machine's reach socket. There is no process for the machine to manage and no library to adopt: the whole contract is below. This is relay contract v1.

app ──▶ machine daemon ──POST {message}──▶ your service   (loopback http)
app ◀── machine daemon ◀──turn.delta / turn.tool / turn.done──   (reach socket)

Compatibility

The machine's daemon is updated by the platform, on every machine, without you doing anything; your service is not. So the contract changes only in ways that cannot break a service written against it:

  1. Within a version, changes are additive only. New fields, new message types and new reach-socket operations may appear. Nothing is removed, renamed or changed in meaning.
  2. Ignore what you do not recognise: an unknown field in a message, or a message type you do not handle. Answer an unknown type with a 2xx and do nothing.
  3. A breaking change is a new version. A machine keeps speaking each older version to every manifest that declares it ("contract"), so a service keeps working until its author moves it.

The reach socket has no version of its own and follows the same rule. To find out whether a machine has an operation, try it: one it does not know answers unknown op: ….

The message you receive

One POST to the manifest's relay address per turn, Content-Type: application/json:

{
  "v": 1,
  "type": "message",
  "chat_id": "3f2b8c1e-7a40-4d8e-9b1c-5e6f7a8b9c0d",
  "turn_id": "t_9d41c0e2a7b34f6e8c1d2a3b4c5d6e7f",
  "text": "summarise the report\n\nAttached: report.pdf",
  "images": [{"mime": "image/png", "data_b64": "iVBORw0KGgo…"}],
  "cwd": "/root/workspace",
  "origin": "user"
}
FieldMeaning
vContract version, 1.
typemessage for a turn, stop for a cancellation (below). Ignore types you do not know.
chat_idThe app chat (a UUID), stable for the chat's life. Key your conversation state on it: each app chat is its own conversation.
turn_idNames this turn. Every reply you send carries it. Random and used once.
textThe owner's message, verbatim. A slash command arrives as typed (/status); nothing is intercepted. Attached files appear as trailing Attached: <name> lines.
imagesZero to eight images, each {"mime", "data_b64"}: base64 with no data: prefix. Always present, possibly empty.
cwdThe chat's working directory, where attached files are.
originuser.

Accepting it

Answer the POST straight away and do the work afterwards. Any 2xx status accepts the turn; the reply itself travels over the reach socket, not in the response. The response body may be empty, or a JSON object with an optional session string, which is recorded with the chat as its session id. It is not sent back to you.

What you doWhat happens
Answer 2xx within 10 secondsThe turn is open and waiting for your reply.
Answer anything else, or take longer than 10 secondsThe turn fails. The status and the first 200 characters of your body go to the log.
Nothing is listening (connection refused)The turn is held and the POST retried every half second for up to 90 seconds, because a service starting beside the machine is normal. It fails after that.

Replying: the reach socket

The reach socket is a unix socket at /run/wireling-reach.sock. Connect, write one JSON object on one line, read one JSON object on one line. A connection may carry any number of requests, one after another. There is no credential: being inside the machine is the permission. Every request has an op; every reply is {"ok": true} or {"error": "…"}.

opFieldsEffect
turn.deltaturn_id, text (non-empty)Appends text to the reply. Send as many as you like: many small ones stream, one large one delivers a whole message.
turn.toolturn_id, id, title; optional kind, input, outputShows a tool card. The first request with a given id opens the card. A request that carries output fills in its result; that can be the same request or a later one with the same id.
turn.doneturn_idEnds the turn successfully. The owner's phone is notified.
turn.errorturn_id, message (non-empty)Ends the turn as failed. message goes to the log; the owner sees the fixed failure line. Text already sent stays in the chat.
→ {"op": "turn.delta", "turn_id": "t_9d41…", "text": "Reading the report. "}
← {"ok": true}
→ {"op": "turn.tool", "turn_id": "t_9d41…", "id": "1", "kind": "read", "title": "report.pdf", "input": {"file_path": "report.pdf"}}
← {"ok": true}
→ {"op": "turn.tool", "turn_id": "t_9d41…", "id": "1", "title": "report.pdf", "output": "42 pages"}
← {"ok": true}
→ {"op": "turn.delta", "turn_id": "t_9d41…", "text": "It argues three things…"}
← {"ok": true}
→ {"op": "turn.done", "turn_id": "t_9d41…"}
← {"ok": true}

The turn.* requests are not rate limited, so stream as finely as you like.

Error replyMeaning
no such turnThe turn_id is not open: it never existed, or it already ended by done, error, stop or silence. Stop sending for it.
turn.delta requires texttext is missing, empty or not a string.
turn.tool requires id, turn.tool requires titleThe named field is missing or empty.
turn.error requires messagemessage is missing or empty.
unknown op: …Not an operation the socket knows.
bad request: …The line was not valid JSON.

How a turn ends

EndingCauseWhat the owner sees
DoneYou sent turn.done.The reply, complete.
ErrorYou sent turn.error, or the POST was not accepted.The fixed failure line.
SilenceFive minutes passed with no turn.delta or turn.tool.The fixed failure line.
StopThe owner pressed Stop.The turn marked as stopped.

Silence is measured from your last activity, not from the start of the turn, and the clock starts when the POST is accepted. A task may run for hours as long as something arrives at least every five minutes; a progress line or a tool card is enough.

Nothing late gets through. Once a turn has ended, for any reason, every further turn.* request for it is refused with no such turn. A slow reply can never leak into the next turn. If your agent has something to say after the turn is over, send it as a note.

Stop

When the owner presses Stop, the turn ends on the machine's side immediately and you are told with a second POST to the same address:

{"v": 1, "type": "stop", "chat_id": "3f2b8c1e-7a40-4d8e-9b1c-5e6f7a8b9c0d", "turn_id": "t_9d41c0e2a7b34f6e8c1d2a3b4c5d6e7f"}

It is sent once, best effort, and nothing waits for your answer. Treat it as a request to abandon that work. Whatever you still send for that turn is refused.

Speaking first: notes

A message your agent starts by itself (a scheduled job's result, a reminder, a question) is a note: it appears in the chat with no turn. Send it on the same socket:

→ {"op": "note", "text": "Your 8am summary is ready.", "source": "agent", "chat": "3f2b8c1e-7a40-4d8e-9b1c-5e6f7a8b9c0d"}
← {"ok": true, "chat_id": "3f2b8c1e-7a40-4d8e-9b1c-5e6f7a8b9c0d"}
FieldMeaning
textRequired.
sourceagent shows the note as from your agent. Anything else, or nothing, shows it as from a script.
fromOptional: a name to show instead, from <name>. 1–32 characters on one line; anything else is refused with from must be 1-32 characters on one line. See reaching the owner.
chatThe chat to post into, normally a chat_id you were given. Without it the note goes to the machine's default conversation. An unknown id is refused: no such chat: <id>.

Notes are rate limited, unlike the reply stream: 30 per five minutes for the whole machine, with a burst of 10. Over the limit the reply is {"error": "rate limited"}. A note may also be sent while a turn is open. The shell equivalent is wireling note --chat <id> [--from <name>] <text>.

Sign-in from the app (optional)

By default a relay agent is shown as Ready and signs in over SSH like any custom agent. A manifest may instead describe how to tell whether it is signed in, and how to sign it in, and the app then offers Set up with a sheet of providers and a terminal, as it does for the built-in agents.

auth is one of two probes, never both:

FormFieldsSigned in when
Filefile (absolute path), optional contains (non-empty string)The file exists, is not empty and, if contains is given, holds that text.
Commandcommand (argv starting with an absolute path), watch (one or more absolute paths)The command exits 0. It runs only when a watched file's size or modification time has changed, and the answer is cached until then. If no watched file exists the answer is signed out and nothing runs. A command that is missing, crashes or takes more than five seconds counts as signed out.

setup has two fields, both required:

loginThe argv that signs in, starting with an absolute path, containing {provider} in exactly one argument. It runs in the app's terminal with the chosen provider's id substituted. Only an id the manifest declares is ever substituted.
providersA non-empty array of {"id", "label", "note"}, all three required. id: a lowercase letter, then up to 31 lowercase letters, digits or dashes, unique in the list. label: 1–24 characters. note: 1–200 characters, shown on the sheet. Use it to say what signing in with that provider costs, so nobody is surprised by a bill.
{
  "kind": "butler",
  "label": "Butler",
  "relay": "http://127.0.0.1:8700/inbound",
  "auth": { "file": "/root/.butler/auth.json", "contains": "access_token" },
  "setup": {
    "login": ["/usr/local/bin/butler", "login", "--provider", "{provider}"],
    "providers": [
      { "id": "openai", "label": "OpenAI", "note": "Uses your ChatGPT plan's included usage." },
      { "id": "api-key", "label": "API key", "note": "Billed per token to the key you paste." }
    ]
  }
}

Declare the two together. With auth alone, an agent that is signed out is simply not usable from the app until someone signs it in over SSH.

Running the service

Your service must listen on a loopback address, be up whenever the machine is, and come back by itself after a restart. A systemd unit does all of that:

root@scout:~# cat > /etc/systemd/system/butler.service <<'EOF'
[Unit]
Description=Butler agent
After=network.target

[Service]
ExecStart=/usr/local/bin/butler serve
Restart=always
EnvironmentFile=/run/ironwire/env

[Install]
WantedBy=multi-user.target
EOF
root@scout:~# systemctl enable --now butler

A systemd service does not see the variables a login shell gets. The EnvironmentFile line gives the service your account and machine environment variables and the IRONWIRE_* identity, which the ironwire command needs.

Two things to keep in mind. Your state is keyed on chat_id and must survive your own restarts, since the machine keeps the chat and will keep sending messages for it. And a restart of the machine's agent software (or of the machine) ends any open turn: requests for it start answering no such turn, and the owner's next message opens a new one.

A complete relay agent

A whole relay agent in Python with no dependencies. It answers the POST at once and replies from a thread.

#!/usr/bin/env python3
import json, os, socket, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

REACH = os.environ.get("WIRELING_REACH_SOCK", "/run/wireling-reach.sock")

def reach(op, **fields):
    """One request line out, one reply line back."""
    with socket.socket(socket.AF_UNIX) as s:
        s.connect(REACH)
        s.sendall((json.dumps({"op": op, **fields}) + "\n").encode())
        return json.loads(s.makefile().readline())

def answer(msg):
    turn = msg["turn_id"]
    reach("turn.delta", turn_id=turn, text="You said: ")
    reach("turn.delta", turn_id=turn, text=msg["text"])
    reach("turn.done", turn_id=turn)

class Inbound(BaseHTTPRequestHandler):
    def do_POST(self):
        msg = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
        self.send_response(200)
        self.send_header("Content-Length", "0")
        self.end_headers()
        if msg["type"] == "message":
            threading.Thread(target=answer, args=(msg,), daemon=True).start()

    def log_message(self, *args):
        pass

ThreadingHTTPServer(("127.0.0.1", 8700), Inbound).serve_forever()

ssh

root@scout:~# python3 echo-relay.py &
root@scout:~# cat > /etc/wireling/agents.d/echo-relay.json <<'EOF'
{ "kind": "echo-relay", "label": "Echo (relay)", "relay": "http://127.0.0.1:8700/inbound" }
EOF

app

Pick Echo (relay) in a new chat. The reply arrives in two pieces, streamed. For anything you keep, run the service as a unit (above) instead of in the background of a shell.

Tool cards

Both shapes feed the same cards. A card is chosen by the call's kind (ACP's kind on a tool call, or the kind field of turn.tool) and filled from its input (ACP's rawInput, or input). The kinds are ACP's standard tool categories.

kindCardInput it reads
executeA command block.command
readA file read.file_path
editA diff, when the input has old_string, new_string or content. Otherwise a plain card labelled with the title.file_path, old_string, new_string, content
searchA search.pattern
fetchA web fetch.url
anything else, or noneA plain card labelled with the title.description

A card's subtitle comes from description, file_path, pattern or url in the input. When none of those is present, the title is used as the description, so a card with only a title still reads well. Each call id produces one card: send the input with, or before, the result.

Debugging

SymptomLook at
The agent is not in the pickerjournalctl -u wireling-daemon | grep 'agent manifest' names the rejected file and the reason. An edited file needs an agent restart; only a new file is picked up live.
Every turn fails with the fixed lineThe same log has the real reason: your ACP agent's stderr and exit, or for a relay the refused connection, the status you returned, relay silent for 300000 ms, or your own turn.error message.
The first message after a boot takes a long timeRelay: your service was not listening yet and the turn was held. The log says gateway not listening yet; holding the turn, then gateway came up; turn delivered.
The agent forgets the chatACP: advertise loadSession and restore from the session id. The process is restarted whenever another chat takes a turn.
Replies answer no such turnRelay: the turn already ended (stopped, silent for five minutes, or the agent software restarted). Send it as a note instead.
The ironwire tools are missingACP: your agent refused per-session MCP servers and the log says so. Relay: add /opt/ironwire/bin/ironwire mcp to your agent's own MCP configuration.
The model choice has no effectACP: modelEnv is read when the process starts, and is unset on Default. Relay: there is no model choice to receive.