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.
| What | Where | |
|---|---|---|
| Waking up | On 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 owner | As 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 | |
| Chats | List the machine's chats with wireling chats; make one for a job with wireling chat new. | Reaching the owner |
| Between machines | Every 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 config | The 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 | |
| Limits | One 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.
| ACP | Relay | |
|---|---|---|
| The agent is | A 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 conversation | The 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. |
| Wire | The 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 lifetime | Managed for you. Expect to be killed and started again. | Yours: a systemd unit or anything else that keeps it up. |
| Model picker | Yes, from the manifest or advertised by the agent. | No. The picker shows Default only; the agent chooses its model. |
| Slash commands | Advertised by the agent over ACP. | Listed in the manifest; the text reaches the agent untouched. |
| Speaking first | Only through wireling note. | Natural: a note, at any time, with no turn open. |
| Streaming text and tool cards | Yes. | Yes. |
| Images from the app | Yes, if the agent advertises image prompts. | Yes, always delivered. |
| Tool permission prompts | Answered 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
| Field | Required | Rules |
|---|---|---|
kind | yes | The 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. |
label | yes | Display name in the picker, 1–32 characters. |
bin or relay | exactly one | Which shape this is. Both, or neither, rejects the file. |
ACP fields
| Field | Required | Rules |
|---|---|---|
bin | yes | Absolute path to the executable that speaks ACP on stdin/stdout. |
args | no | Arguments to pass: an array of non-empty strings. |
models | no | Choices 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. |
modelEnv | no | Name 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
| Field | Required | Rules |
|---|---|---|
relay | yes | The 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. |
contract | no | The 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. |
commands | no | Slash 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. |
auth | no | How the machine tells whether the agent is signed in. See sign-in from the app. Without it the agent always shows as Ready. |
setup | no | The 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 time | A 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 directory | Each chat has one directory, fixed at its first turn (default /root/workspace). Your agent is told which. |
| Attached files | A 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. |
| Images | Up to eight per message, delivered inline as base64. |
| Storage and push | Every turn is written to the machine's disk and a finished reply rings the owner's phone. Your agent does nothing for either. |
| Failures | A 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. |
| Reach | wireling 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.
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.
| Command | Socket op | What it does |
|---|---|---|
wireling chats | chats | Lists 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> | note | Posts 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
| Command | bin with args, as root, with stdin, stdout and stderr piped. |
| Working directory | The chat's directory. The same path is passed as cwd when the session opens. |
| Environment | The 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. |
| stdout | Protocol only: one JSON-RPC message per line. A line that is not JSON is skipped, but keep logging off stdout. |
| stderr | Copied 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
| Step | What the machine sends | What it needs back |
|---|---|---|
initialize | Protocol 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/new | cwd and mcpServers, holding one stdio server named ironwire. | sessionId. It is stored with the chat. |
session/load | Instead 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/prompt | One per turn: the image blocks (if you take images), then one text block. | Stream session/update notifications, then resolve with a stopReason. |
session/cancel | When 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
sessionUpdate | What happens |
|---|---|
agent_message_chunk | Text content is streamed into the reply. Non-text content is ignored. |
agent_thought_chunk | Treated the same as message text: it appears in the reply. Do not send thoughts you would not want shown. |
tool_call, tool_call_update | One 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_update | Replaces the composer's / menu: each command's name, description and input hint. Honoured at any time, including right after the session opens. |
| anything else | Ignored (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 manifest | models fills the picker and modelEnv delivers the chosen id as an environment variable when the process starts. |
| Over ACP | If 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:
- 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. - Ignore what you do not recognise: an unknown field in a message, or a message
typeyou do not handle. Answer an unknowntypewith a 2xx and do nothing. - 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"
}
| Field | Meaning |
|---|---|
v | Contract version, 1. |
type | message for a turn, stop for a cancellation (below). Ignore types you do not know. |
chat_id | The app chat (a UUID), stable for the chat's life. Key your conversation state on it: each app chat is its own conversation. |
turn_id | Names this turn. Every reply you send carries it. Random and used once. |
text | The owner's message, verbatim. A slash command arrives as typed (/status); nothing is intercepted. Attached files appear as trailing Attached: <name> lines. |
images | Zero to eight images, each {"mime", "data_b64"}: base64 with no data: prefix. Always present, possibly empty. |
cwd | The chat's working directory, where attached files are. |
origin | user. |
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 do | What happens |
|---|---|
Answer 2xx within 10 seconds | The turn is open and waiting for your reply. |
| Answer anything else, or take longer than 10 seconds | The 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": "…"}.
op | Fields | Effect |
|---|---|---|
turn.delta | turn_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.tool | turn_id, id, title; optional kind, input, output | Shows 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.done | turn_id | Ends the turn successfully. The owner's phone is notified. |
turn.error | turn_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 reply | Meaning |
|---|---|
no such turn | The 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 text | text is missing, empty or not a string. |
turn.tool requires id, turn.tool requires title | The named field is missing or empty. |
turn.error requires message | message is missing or empty. |
unknown op: … | Not an operation the socket knows. |
bad request: … | The line was not valid JSON. |
How a turn ends
| Ending | Cause | What the owner sees |
|---|---|---|
| Done | You sent turn.done. | The reply, complete. |
| Error | You sent turn.error, or the POST was not accepted. | The fixed failure line. |
| Silence | Five minutes passed with no turn.delta or turn.tool. | The fixed failure line. |
| Stop | The 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"}
| Field | Meaning |
|---|---|
text | Required. |
source | agent shows the note as from your agent. Anything else, or nothing, shows it as from a script. |
from | Optional: 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. |
chat | The 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:
| Form | Fields | Signed in when |
|---|---|---|
| File | file (absolute path), optional contains (non-empty string) | The file exists, is not empty and, if contains is given, holds that text. |
| Command | command (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:
login | The 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. |
providers | A 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.
kind | Card | Input it reads |
|---|---|---|
execute | A command block. | command |
read | A file read. | file_path |
edit | A 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 |
search | A search. | pattern |
fetch | A web fetch. | url |
| anything else, or none | A 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
| Symptom | Look at |
|---|---|
| The agent is not in the picker | journalctl -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 line | The 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 time | Relay: 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 chat | ACP: advertise loadSession and restore from the session id. The process is restarted whenever another chat takes a turn. |
Replies answer no such turn | Relay: the turn already ended (stopped, silent for five minutes, or the agent software restarted). Send it as a note instead. |
| The ironwire tools are missing | ACP: 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 effect | ACP: modelEnv is read when the process starts, and is unset on Default. Relay: there is no model choice to receive. |