Hand a task to a Claude Code agent running natively on the user's Mac, when this session cannot do it itself. Use whenever a task needs something the Cowork VM does not have — the real network (SSH, cluster, cPanel, home LAN, any host), macOS-native tools (launchctl, security/Keychain, tmux, the Chrome extension), a long-running background agent, or continuity with an ongoing piece of work the user has been doing on the Mac (their lab, their sites, their tooling — the current list lives in br...
Scanned 10/6/2026
npx -y skills add ZsoltSziklai/claude-code-agent-spawner --skill agent-bridge --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Agent Bridge?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/zsoltsziklai-agent-bridge)More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.
---
name: agent-bridge
description: Hand a task to a Claude Code agent running natively on the user's Mac, when this session cannot do it itself. Use whenever a task needs something the Cowork VM does not have — the real network (SSH, cluster, cPanel, home LAN, any host), macOS-native tools (launchctl, security/Keychain, tmux, the Chrome extension), a long-running background agent, or continuity with an ongoing piece of work the user has been doing on the Mac (their lab, their sites, their tooling — the current list lives in bridge/agents.json). Also use it when the user says things like "indítsd el a gépemen", "csináltasd meg a mac-mainnel", "add át a infra agentnek", or mentions the bridge, bridge/requests, or a request id. Do not use it for work this session can finish on its own inside the mounted folder.
---
# agent-bridge
This session runs in an **isolated Linux VM**. The user's Mac is a separate
machine. There is no network out, no `launchctl`, no `rm`, and no
agent-to-agent messaging — but files written into a **mounted folder land on
the Mac's real disk**. That one channel is the whole bridge.
On the Mac, a launchd watcher picks up what you drop, validates it, asks the
user for approval **in Telegram**, and only then starts an agent. That agent
starts from the parent you name — in its project, with its tools — but by
default as a **fresh session that knows only your task**. It does the work with
full native access, and its answer comes back to you as a file in
`bridge/results/`.
## Before anything else: is the folder mounted?
`~/ClaudeProjects/bridge/` must be visible to you. If it is not, the user has
not attached the folder to this session — say so and stop. Do not try to
create the folder somewhere else; a request outside the real mount reaches
nobody.
## Choosing the parent — this is the important decision
The parent decides **where** the new agent works — its project and working
directory, its tools, and the tree it belongs to (closing the parent closes it
too). It does **not** decide what the agent knows: by default the child starts
fresh and knows only your `task` (see `resume` below). Pick the parent whose
project the work belongs to; `about` tells you that.
**Read `bridge/agents.json` first.** The Mac keeps it current — never work from a
list you remember, because the agents change:
```json
{
"updated_at": "2026-08-07T21:49:41Z",
"parents": [
{"name": "mac-main", "running": true, "about": "the agent system, the bridge, watchdogs"}
],
"spawned": [
{"name": "mac-main-dpelda-20260101", "running": true,
"request": "db-list-20260807", "since": "2026-08-07T21:02:12Z"}
]
}
```
- **`parents`** — the only names accepted for a new fork. `about` says what each
one already knows; pick on that, not on the name.
- **`spawned`** — agents the bridge started earlier. These are what you can
*continue* (`agent`) or *close* (`action: close`).
- **`running: false`** is usually not a blocker: a continuation resumes the
session first. For a new fork, prefer a parent that is running.
Two exceptions:
- **A root agent** (anything in `parents`, e.g. `mac-main`) is *never* resumed
by the bridge. It has no spec — a watchdog owns its lifecycle — and starting
a second process under the same name would steal the running session's
Remote Control connection. If it is down, the answer is `failed` and you wait
for the watchdog; say so rather than retrying.
- If the agent has no recorded session id *and* shares its cwd with another
session (typical for forks without a worktree), the Mac answers `failed`
instead of guessing — the "newest transcript in this cwd" could be the
parent's, and resuming that would put two processes on one conversation.
If the file is missing, the Mac-side watcher is not running — say so and stop.
If no parent fits the task, say that too rather than guessing; a fork from the
wrong parent lands in the wrong project and wastes the user's tokens.
## Two kinds of request
| you want | field | what happens |
|---|---|---|
| a **new** agent for a new piece of work | `parent` | a fresh agent is forked — it does **not** inherit the parent's conversation unless you ask (`resume`) |
| to **continue** with an agent that already did work for you | `agent` | your text goes into that same session — no new agent, nothing re-discovered |
Use `agent` whenever you are following up on something a spawned agent already
investigated. Forking again would make it redo the discovery from scratch and
cost the user tokens for nothing. The two fields are mutually exclusive.
For `agent`, take the name from `bridge/agents.json` (`spawned` list) or from
the `.status` message that reported it. It must descend from one of the allowed
parents. If it is no longer running, the Mac resumes its session first,
then delivers your message — you don't have to handle that.
```json
{
"agent": "mac-main-dpelda-20260101",
"task": "Most nézd meg azt is, amit az előbb kihagytál."
}
```
A continuation normally needs the user's approval in Telegram, exactly like a
new agent. Nothing reaches a running session without them pressing the button —
with two documented exceptions: a standing approval (the ⏱ buttons, which cover
`continue`/`reconnect` for one named agent until the window expires) and
`gate: "audit"` in `bridge-allow.json`, where every allowed request runs at once
and Telegram only reports it afterwards. Under either, `pending` is not a
resting state — it may flash by for as long as the spawn takes — so wait for a
*final* status (`spawned` / `rejected` / `failed` / `expired`), never for
`pending` to appear.
**One exception, and it changes what you should wait for.** When the user
approves, they can also grant a **standing approval** for that agent — 1 hour,
8 hours, or 1 day. While it lasts, further continuations *and* reconnects to
that same agent start immediately, with no button press. New forks and closes
are never covered.
You cannot see whether a grant is active, and you don't need to: just don't
assume you will observe `pending`. Every request is written as `pending`
first, but under a grant the execution follows immediately, so the window can
be shorter than your poll interval. Poll for a *terminal*
status, never for `pending` specifically — a loop that waits to see `pending`
first can miss it entirely and hang forever.
## When an agent vanishes from the phone but still runs
Occasionally an agent's Remote Control connection drops: it disappears from the
user's Code tab while the process keeps running on the Mac. There is no reliable
way to detect this locally — **the user is the only sensor**. When they say an
agent has vanished, ask for a reconnect:
```json
{
"action": "reconnect",
"agent": "mac-main-dpelda-20260101"
}
```
The session stops and restarts with `--resume`, which registers a fresh Remote
Control connection. **The conversation is not lost** — the same session
continues, with full context. Only agents the bridge started can be reconnected;
the user's own root sessions are handled by their watchdog.
## Closing an agent you started
When an agent you spawned has finished and the user has the answer, you can ask
for it to be closed:
```json
{
"action": "close",
"agent": "mac-main-dpelda-20260101",
"code": "drop",
"context": "keep"
}
```
| `code` | effect |
|---|---|
| `merge` | the agent's branch merges into its parent's, then worktree and branch are removed |
| `drop` | worktree and branch are deleted — **the code work is lost** |
| `nowt` | only the session stops; the worktree stays |
`transcript` is `keep` (default) or `delete`. **`delete` is irreversible** — the
agent's own `.jsonl` is removed from disk. Only ask for it when the user has
explicitly said the conversation is disposable; the default exists because these
transcripts are the record of what actually happened.
`context` is `keep` (default) or `merge` (splice its conversation into the
parent's). **Transcripts are never deleted either way.**
**You can only close agents the bridge itself started** — the `spawned` list in
`bridge/agents.json`. Everything in `parents`, and anything the user launched by
hand, is refused by design: closing cascades to descendants, so closing a root
would take their whole working environment with it. Don't try to work around
this; report the refusal instead.
Closing needs approval in Telegram like everything else, and the summary shows
exactly which agents the cascade will reach.
## Writing the request
Create `bridge/requests/<id>.json`. Keep `<id>` short and unique — max 48
characters, `A-Za-z0-9._-` only (it has to fit in a Telegram button).
```json
{
"parent": "mac-main",
"task": "Nézd meg, miért nem indul a szolgáltatás, és javasolj fixet.",
"worktree": true,
"model": "opus",
"effort": "high",
"cwd": "valamelyik-alkonyvtar"
}
```
| field | required | notes |
|---|---|---|
| `parent` | one of the two | new fork — a name from `agents.json` → `parents` |
| `agent` | one of the two | continuation — an existing agent's exact name |
| `task` | **yes** | max 8 KB (bytes, not characters — accented text counts double); write it for someone who cannot see this conversation |
| `worktree` | no | **default `true`**; set `false` only if the task needs the parent's uncommitted work |
| `model` | no | **default `opus`**. An alias (`opus` / `sonnet` / `haiku` / `fable`) always means the *latest* model of that family, so prefer it. Pin an id only when you deliberately want a fixed version: `claude-opus-5-5`, `claude-sonnet-5-5`, `claude-fable-5-1`, `claude-opus-5`, `claude-sonnet-5`, `claude-fable-5`, `claude-haiku-4-5`, `claude-opus-4-8`, `claude-opus-4-7` (optional `[1m]` suffix on any of them, aliases included) |
| `effort` | no | `low` … `max` |
| `permission_mode` | no | **default `auto`**. Also `acceptEdits`, `plan`, `dontAsk`, `manual`, `bypassPermissions` — see the note below before asking for the last one. **Haiku cannot use `auto`** — give it `dontAsk` (unattended) or `manual`, or the request is rejected |
| `resume` | no | fork only. **default `none`** — a fresh session that knows only your `task`. `summary` / `full` pass on the parent's conversation; ask for them only deliberately. See below |
| `cwd` | no | relative to `~/ClaudeProjects`; defaults to the parent's |
### `resume` — how much of the parent the child inherits
| value | the child gets |
|---|---|
| `none` (**default**) | **nothing** — a fresh session that knows only your `task` |
| `summary` | a compacted version of the parent's conversation |
| `full` | the parent's whole conversation |
**Write every task as if the child had never heard of the work** — because by
default it hasn't. Name the files, the hosts, the goal and what "done" means. A
task like "fix the thing we discussed" reaches a child that has no idea what was
discussed.
The default used to be `full`, and it was changed for a measured reason. On
2026-08-31 the child of an inheriting fork did not do its task: it **continued the
parent's role**, monitoring a run as the command center with no work of its own.
In its transcript the task sat on line 703 of 714 — one short message after 700
lines of "you are the command center". A sentence telling it to treat the history
as background was there and was not enough. With `none` there is nothing to
override.
Ask for `summary` or `full` only when the task genuinely needs the parent's
history. Inheriting is also slow: the child loads the whole conversation before
its first turn — **minutes** on a long-running parent — so give it time, and do
not conclude it is broken because no report appeared at once.
### When to ask for a worktree
**A worktree is the default.** Omit the field and the agent gets its own git
branch and working copy — work that can be reviewed, merged, or thrown away in
one step. An unused branch costs nothing.
Set `worktree: false` only for one specific reason:
> **The task depends on the parent's uncommitted work.**
A worktree is checked out from HEAD, so the parent's modified and untracked
files are **not in it**. If you ask an agent to "finish the script I was
editing" inside a fresh worktree, it won't find the file — and it may quietly
rewrite it from scratch instead of failing. That is the failure this switch
exists to prevent.
Without a worktree the agent runs **in the parent's own working directory**:
whatever it writes lands directly there, with no branch to review or discard. It
also shares the parent's transcript folder, which is why `transcript: delete`
removes only its own file and never the folder.
If in doubt, leave the field out and take the worktree.
**You may set `permission_mode`** (`auto`, `acceptEdits`, `plan`, `dontAsk`,
`manual`, `bypassPermissions`); it defaults to `auto`.
One exception: `bypassPermissions` is the only *elevated* mode, and it only
takes effect if the user pressed the Telegram button for **that specific
request**. On the two unattended paths — under a standing grant, or in
`gate: "audit"` — the bridge silently downgrades it to `auto`. So ask for it
only when the task genuinely needs it, and never assume you got it.
Write the task so it stands alone. The agent does not see your conversation —
and by default not the parent's either. It has no idea what the user just told
you.
## Many forks under one approval — experiment authorisation
Some work needs dozens or hundreds of forks — typically a measurement, where one
agent runs a protocol that forks the same parents again and again. One Telegram
button per fork is not workable, and **leaving the gate out is not an option**:
then nobody approved anything, and a reviewer asking "who authorised what" gets no
answer. Instead you ask **once**, for the whole package, and the user says yes or
no.
You will usually not fork yourself — you have no shell on the Mac. Your part is to
**file the authorisation** and hand its id to the agent that runs the protocol.
**1. The parents must already be running**, under their final names. The
authorisation names them exactly (no prefixes), so they are built first — usually
by the executing agent, through the normal gate.
**2. Write `bridge/requests/<id>.json`:**
```json
{
"action": "experiment",
"requested_by": "mac-main-dexp0-pilot-10",
"parents": ["mac-main-xr10-a", "mac-main-xr10-b"],
"forks": 290,
"hours": 8,
"parallel": 5,
"model": "claude-haiku-4-5-20251001",
"no_ask": true,
"permission_mode": "dontAsk",
"purpose": "role-continuity pilot, protocol v3"
}
```
| field | required | notes |
|---|---|---|
| `requested_by` | **yes** | the agent that will use it — recorded in the authorisation |
| `parents` | **yes** | 1–10 exact names; each must be running and descend from an allowed root (a root itself cannot be a parent) |
| `forks` | **yes** | the total number of forks |
| `hours` | **yes** | how long it lives |
| `parallel` | **yes** | how many may run at once (≤ `forks`) |
| `model` | **yes** | one specific model; dated ids work (`claude-haiku-4-5-20251001`) — a measurement should not use an alias, because aliases move |
| `no_ask` | no | default `true` — every child gets `--no-ask` |
| `permission_mode` | no | default `auto` — every child runs in it. **Required for Haiku** (see below) |
| `purpose` | no | ≤ 500 bytes; shown to the user on the approval message |
⚠️ **Haiku cannot run in `auto` mode**: the CLI silently switches it to `manual` ("auto mode unavailable for this model"), and an unattended agent then stops at its first permission prompt — forever. A Haiku request without an explicit mode is therefore rejected. Use `dontAsk` for unattended work (it refuses what is not allowed instead of waiting), `manual` only if someone answers.
**Get the numbers right before you file.** The user approves or rejects the
package as it is — there is no "approve, but only 50" button. A request above the
sanity ceiling in `bridge-allow.json` (default 500 forks / 24 h / 10 parallel) is
rejected before it reaches the phone, and `bypassPermissions` is never allowed.
**Forking from a frozen snapshot** (only when the protocol asks for it): add
`"snapshots": ["<sid>", …]` (1–20 session ids) and `"snapshot_holder": "<name>"`.
The holder must be one of `parents`; it is the parent name every child gets, so
it should be neutral. Each snapshot must already exist as exactly one file
`~/.claude/projects/<cwd-slug>/<sid>.jsonl` with every `sessionId` set to the sid —
otherwise the request is refused. The bridge records each file's sha256 at
arrival, and the user sees it on the approval message; a snapshot changed after
approval cannot be forked from. The executing agent then adds
`--from-snapshot <sid>` to its `agent-exp-fork` call. You do not build snapshots
yourself — that is the executing agent's job, before it files.
**3. Wait for the final status** like any other request. On approval it is
`spawned`, with the message `kísérlet-felhatalmazás érvényes: <id> — lejár …`.
**4. Hand the id to the executing agent** (a continuation request), with these
facts — it needs all of them:
- it forks from a **parent's session** with
`/Users/<user>/.claude/agent-queue/bin/agent-exp-fork <id> <suffix> [--cwd …] [<prompt>]`,
spelling the path out exactly like that (the sandbox exclusion matches the text
of the call, so `~/…` would not match);
- exit **75** means the parallel slots are full: wait for a child to close and
retry. Any other refusal is final, and the message says why;
- the authorisation enforces parent, model, `--no-ask`, the count, the parallel
cap and the expiry — a fork that does not fit is refused, not adjusted;
- the user is told at 25 / 50 / 75 / 100 % and on expiry, and can **stop** it from
Telegram at any time: running children continue, new forks are refused.
**5. For the replication package**, the executing agent runs
`/Users/<user>/.claude/agent-queue/bin/agent-grant-export <id>`: who approved what
and when, the exact limits, how much was used, and every fork that started under it.
If the executing agent reports that the parent's session cannot run
`agent-exp-fork` (sandbox or permission refusal), that is a setting on the user's
side — tell the user; do not look for a way around it.
## Reading the mount is not free — you get a snapshot
This is the trap that costs the most, and it is invisible: **re-reading a path
you already read gives you the copy you fetched the first time, not what is on
the Mac now.** The mount is materialised on demand. A file the Mac rewrote two
minutes ago still reads as the old version, and a file that appeared after your
first look does not appear at all.
So a polling loop built on plain file reads never terminates — it re-reads a
stale `pending` forever while the real status has said `spawned` for an hour.
That is not a hypothetical; it happened on 2026-08-11 and the user had to point
it out.
**Every poll must go through the device-bridge tools**, which fetch afresh:
- to see whether a file changed → re-stage it, then read the staged path
- to see whether a file *appeared* → **list the directory**; the listing is
always current, and its `mtimeMs` tells you what moved without reading anything
Listing first is also cheaper: one call over `bridge/results/` tells you which
ids are new and which just changed, and you only fetch the one you want.
## What happens next, and how you find out
Poll `bridge/requests/<id>.status`. There is no network and no callback; the
file is the only signal.
```json
{ "status": "spawned", "message": "fork kész: <az új agent neve>", "updated_at": "…" }
```
| status | meaning |
|---|---|
| `pending` | waiting for the user to press the button in Telegram — under a standing approval or in audit mode it is only a brief transition, not a state to wait for |
| `spawned` | approved and running |
| `rejected` | the user declined, or validation failed — `message` says why |
| `failed` | approved but the start failed — `message` says why |
| `expired` | no decision within 24 hours |
A status file appears within a second or two. **If none appears at all**, the
watcher is not running or the folder is not the real mount — tell the user,
don't retry.
## Reading the answer
You read `bridge/results/<id>.md`. But **the Mac writes that file, not the
agent.** The agent writes `.bridge-result-<id>.md` into its own working
directory — one file per request, named after the request — and a poller lifts
it across within ~30 s. The instruction is appended to every task automatically.
**Absence of a result does not mean the work stalled.** A report can be up to
~30 s in flight, and a freshly written one waits one extra cycle so it is never
taken mid-write. Beyond that, the agent may simply still be working. Treat a
missing file as "not yet", never as evidence of a problem — and never as a
reason to re-send the task.
**Therefore: never put a results path in your task text.** An agent running in a
worktree is sandboxed to its own directory; a redirect into `bridge/results/`
fails with `operation not permitted` — but the shell does not stop, so the agent
reports success for a file that never existed and you wait forever. (Measured
2026-08-11.)
**A continuation's answer lands under the id of that continuation** — the id you
just wrote. Until 2026-08-13 it landed under the id of the request that first
spawned the agent, which is why older notes call the naming unpredictable; the
mapping is now refreshed on every continuation.
Even so, do not poll a single filename:
> **List `bridge/results/` and look at what changed by `mtimeMs`.**
One call, and it finds the answer whichever name it takes — including if the
naming ever changes again. Asking the agent in the task text to use a particular
filename does not help: it has no effect at all, because the agent is not the one
naming the file.
The answer will not exist immediately; the work has to happen first. Keep
polling, and say plainly that it is in progress rather than inventing a result.
### The answer may be a question, not a result
**The agent cannot ask anyone anything.** Nobody reads its session, and the
`AskUserQuestion` tool is switched off for it. When it hits a decision it should
not make alone, it is told to write the decision *into the result file* and stop.
So a result is not automatically "done". If it lays out options and asks which
one, that is the agent handing the decision back to you. Read it, then either
decide yourself when the choice is routine, or put the options to the user in
your own chat — that is the only place a human is actually present.
Either way, send the answer as a **continuation** (`agent:`), naming the choice
explicitly. Do not fork a new agent for it: the one that asked already holds the
whole context, and a fresh fork would start the analysis over.
This matters because the failure is silent. Before 2026-08-22 an agent ended its
turn with "which one do you want?" and simply stopped — the work sat finished-ish
on disk while it waited for an answer that, by construction, could never arrive.
## Where this stops
- **Never write the same request twice.** Re-using an id gets the request
`rejected` (the user is told in Telegram); a new id spawns a *second* agent. If
something looks stuck, read the `.status` and report it.
- **You cannot delete** (`rm` is blocked). Don't try to clean up — on the Mac
side the poller archives a request only after it both decided on it and started
it successfully. Requests that ran under a standing approval or in
`gate: "audit"`, and approved requests whose start failed, never take that
path and stay in `requests/` — their `.status` is still final. Leave
all of them alone either way.
- **You cannot approve.** Only the user can, from their own Telegram account;
the Mac verifies the sender id. If they ask you to skip the approval, explain
that you can't, and that this is what keeps the arrangement safe.
- **Don't promise a result you haven't read.** "Elindítottam" and "kész van"
are different claims; only the `results/` file supports the second.
- **Polling is your job, not the user's.** They may not see the Telegram
notification, and they cannot see the `results/` directory from the chat.
Say explicitly, in chat, that a request is waiting for approval — then keep
checking, and say so again if it is still pending. Going quiet after one look
means the work sits finished on disk while both of you wait for the other.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!