A standalone HTTP broker that receives external webhooks (CI, GitHub, Flowise…) and
wakes up a coding agent — Claude Code or free-code — either as a brand-new session or
by resuming an existing one — without the agent having to be running beforehand. Two
runtimes are supported in the spawn adapter: Claude Code (the default)
and free-code; they share the same hook protocol, so the broker and
callers stay runtime-agnostic — you just pick which CLI a hook spawns with
--runner.
node:sqlite (no native dependencies) and runs the .ts files directly — there's no build step.claude and/or free-code binary on the PATH of the machine where the broker runs. A hook spawns whichever one its --runner selects (default claude), so you only need the binary/ies you actually use — free-code is optional, install it only if a hook will use spawn:free-code.Not published to npm yet: it's used straight from the repo checkout.
cd agent-webhook-bridge
npm install # optional -- only brings in @types/node for the editor,
# not needed to run anything
To have the awb command available from any folder:
npm link # creates the global symlink "awb" -> ./cli/awb.ts
If you'd rather not link it globally, every awb … command in this guide works the same as node cli/awb.ts … run from the repo folder.
awb start
Runs in the foreground and listens on 127.0.0.1:8890 by default (chosen on purpose to avoid clashing with free-code's webhook-receiver, which uses 8787-8806).
Configuration (registered hooks) lives in ~/.agent-webhook-bridge/hooks.json, the event queue in ~/.agent-webhook-bridge/events.db (SQLite), and the logs of every Claude invocation in ~/.agent-webhook-bridge/logs/.
To use a different location (tests, multiple instances), set AWB_HOME=/your/path before any awb command.
The broker re-reads hooks.json on every request, so you can register or remove hooks with awb add/awb rm while it keeps running, no restart needed.
awb add ci-failures \
--trigger \
--workdir /path/to/your/repo \
--prompt-template 'A CI build failed. Log:\n\n{{payload}}\n\nInvestigate the cause.'
The output returns the local URL and the secret you need to configure in the external system:
Hook 'ci-failures' [trigger] consumers=spawn:claude
Local URL: http://127.0.0.1:8890/hook/ci-failures
Header: X-Webhook-Secret: 76171625f1dbab4647623cb7f6c99e18...
Workdir: /path/to/your/repo
Prompt: A CI build failed. Log:\n\n{{payload}}\n\nInvestigate the cause.
| Option | What it does |
|---|---|
--trigger / --queue | Delivery mode. trigger (default) fires the spawn adapter as soon as the event arrives. queue only persists it in SQLite, for a future read adapter (see Current limitations). |
--runner <claude|free-code> | Which CLI a trigger hook spawns. Default: claude. Shortcut for --consumer spawn:claude / --consumer spawn:free-code; an explicit --consumer wins. The two adapters share the same hook protocol (secret, callbackUrl, sessionId), so the broker and callers don't change — only the spawned binary does. See free-code adapter for the session/permission differences. |
--consumer <c> | Repeatable. Default: spawn:claude for trigger (or spawn:free-code with --runner free-code), queue for queue. Use spawn:free-code to wake free-code instead of Claude Code. |
--prompt-template <text> | Template for the prompt the spawned agent receives. {{payload}} is replaced with the event body (formatted as JSON if it isn't plain text); {{hook}} with the hook's name. |
--workdir <dir> | cwd of the spawned process. It's also the key that serializes runs: two events with the same workdir never run in parallel on the same repo. |
--secret <s> / --hmac-secret <s> | Authentication. If you don't provide either, a random one is generated. With --hmac-secret the caller signs the raw body instead of sending the secret in a header. |
--permission-mode <mode> | For --runner claude this is passed straight through as claude's --permission-mode (acceptEdits, auto, bypassPermissions, manual, dontAsk, plan). For --runner free-code it's mapped to that adapter's --tools flag (see Permissions in headless mode). Without this, headless runs can't write or edit anything. |
--visible | Runs the spawned agent (claude or free-code) in a visible gnome-terminal window instead of hidden (see Visible mode). |
curl body is yours, not a fixed format
The bridge doesn't expect any particular field in the body — no log, no
branch, nothing. You decide what your external system sends (or what you
type by hand to test); the broker just takes that body as it arrived and drops
it wherever {{payload}} appears in your --prompt-template.
There's no field-by-field substitution — it's a single block of text.
With the hook above, this request:
curl -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret>" \
-d '{"branch":"main","step":"npm test","exitCode":1}'
ends up building this exact prompt, which is what claude -p receives as its argument:
A CI build failed. Log:
{
"branch": "main",
"step": "npm test",
"exitCode": 1
}
Investigate the cause.
Claude receives that JSON as plain text inside the prompt and interprets it itself —
there's no special parsing of branch/step/exitCode
on the broker's side. That's why different examples in this guide have bodies of
different shapes ({"log": "…"}, {"flow": "…"}, {"branch": "…"}):
it's not that each one follows a required format, it's that each one simulates what a
different system would send (a test runner, a Flowise flow, a CI step). You decide the
shape of your own caller's body, and you write the --prompt-template to
make sense of that shape.
If Content-Type isn't application/json or the body isn't
valid JSON, {{payload}} still gets replaced, just with the raw text as-is
— plain text in -d works fine too, it doesn't have to be JSON.
sessionId)
For hooks with a spawn:* consumer, the adapter decides how to launch the
agent based on a single header in the incoming request. Both adapters use the same
header (sessionId), so the caller's protocol is identical regardless of
--runner — only the value differs: a Claude Code session uuid for
claude, a .jsonl path for free-code.
--runner claude (default)For --resume to find the session, the hook's --workdir has to match the directory of the project where that session was originally created — Claude Code sessions are tied to a working directory.
--resume <id> without -p/--print opens Claude Code's interactive picker. In a spawned process with no TTY that hangs forever — that's why the adapter always adds -p on the resume branch too, not just on the new-session one.
--runner free-codefree-code resumes by .jsonl path, not by a uuid. The adapter keeps every session file under ~/.agent-webhook-bridge/sessions/<hook>/, and the session_id it returns in the callback is that path — so to continue a conversation you just send it back as the sessionId header, exactly like with claude:
A sessionId header that points outside ~/.agent-webhook-bridge/sessions/ is treated as untrusted (path-traversal guard) and a fresh session starts instead — the broker never opens an arbitrary caller-supplied path.
free-code's --mode json is not a single envelope the way claude's --output-format json is: it emits an NDJSON event stream (a session header line, then one event per message/turn/tool). The adapter consumes that stream and reshapes it into the same {result, session_id} envelope the claude adapter produces, so the callback body and the AgentMesh hub see one uniform shape from either runtime.
sessionId I need to send?
The broker and Claude don't invent it: it's the caller (your script,
your Flowise flow, your CI pipeline) that has to know it and send it as a literal HTTP
header sessionId: <uuid> on the POST. Three ways to get
that UUID:
--output-format json returns a session_id field in its JSON
output — including the one that starts claude -p without resuming. Save
it the first time and reuse it on the next calls to the same hook to keep following
that same conversation.
<sessionId>.jsonl inside
~/.claude/projects/<your-encoded-project>/ (one folder per project,
with dashes instead of /). You'll also see it in Claude Code's
/resume picker.
~/.agent-webhook-bridge/logs/<hook>-<timestamp>.log, with
Claude's full response — the session_id used is in there too.
Example of capturing the id on the first call and reusing it on the second:
# 1) first call: no sessionId, starts a brand-new session.
curl -s -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <your secret>" \
-d '{"log":"npm test exit 1"}'
# 2) read the session_id from that run's most recent log for the hook
SID=$(ls -t ~/.agent-webhook-bridge/logs/ci-failures-*.log | head -1 \
| xargs cat | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4)
# 3) second call to the same hook, now resuming that session
curl -s -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <your secret>" \
-H "sessionId: $SID" \
-d '{"log":"same build still failing, second attempt"}'
The whole flow works the same for either runner — the SID value just
differs, a uuid for claude and a .jsonl path for free-code. For
--runner free-code you can even skip the log grep: the first call's
callback body already carries session_id as that .jsonl path
(same as claude's callback does), so capture it there if you're using
callbackUrl.
A spawned run has no terminal. What happens when the prompt asks the agent to write or edit a file depends on the runner:
--runner claude (default): Claude Code auto-denies Write/Edit/Bash when no one is there to approve the permission, and explains it couldn't do it. This happens even when resuming a session you've been using interactively without issues — the permission doesn't "carry over" to a new headless run.--runner free-code: free-code has no runtime auto-deny; instead the adapter scopes what the agent can attempt by choosing its --tools flag set from the hook's --permission-mode (see the table below). With the default (no --permission-mode) it runs read-only, so the agent literally has no Write/Edit/Bash tool to call — it answers from reading alone.Real log from a claude hook without --permission-mode being asked to save a file:
"result": "I still don't have permission to write the file -- the system requires
someone to approve the write action from the interactive session ...",
"permission_denials": [{ "tool_name": "Write", "tool_input": { "file_path": "..." } }]
The fix is to declare --permission-mode when registering the hook:
awb rm resume
awb add resume --trigger \
--workdir /home/lenovo/Documentos/free-code/free-code \
--permission-mode acceptEdits
With that, the same request ends up actually writing the file:
"result": "Saved to `/home/lenovo/Documentos/free-code/free-code/resumen-sesion-flowise-webhook.md` (root of the working directory)."
| Mode | What it authorizes (claude) | free-code --tools it maps to |
|---|---|---|
| (unset) | Auto-denies Write/Edit/Bash — the agent answers but can't mutate. | read,grep,find,ls (read-only). The safe AgentMesh default. |
acceptEdits | Auto-approves file Write/Edit. This is the one that fits most automation hooks — it doesn't enable anything else. | read,edit,write,grep,find,ls (no bash). |
bypassPermissions | Skips all permission checks, including arbitrary Bash. Same risk as --dangerously-skip-permissions: only makes sense in an isolated sandbox, not on your machine with your real repos. | read,bash,edit,write,grep,find,ls (full, incl. bash). Same risk profile. |
dontAsk | Tested and it does not authorize Write/Bash despite the name -- in a real headless run it still denied the write, same as leaving this unset. Don't rely on it to enable edits. | maps to the full set like bypassPermissions — for free-code, where there's no runtime prompt to answer, dontAsk means "don't gate it", so it's treated as full access. Same risk. |
auto | Other claude mode, untested here; see claude --help. | maps to the full set like bypassPermissions. |
plan / manual | Other claude modes that prompt; a spawned run has no TTY to confirm. | read,grep,find,ls (read-only) — there's no one to confirm a prompt. |
permissionMode is left unset by default on a new hook —
you have to ask for it explicitly. The reason: once a hook can write without asking,
anyone who knows the hook's secret can make the spawned agent write files in that
workdir with whatever prompt they want. Only use it on hooks where you
trust the event source and what the --prompt-template might end up
asking the agent to do.
By default a spawned run shows nothing — the command runs hidden and all its
stdout/stderr goes straight to the log. With --visible, instead, a
gnome-terminal window opens showing the exact command and the response:
awb add resume --trigger \
--workdir /home/lenovo/Documentos/free-code/free-code \
--permission-mode acceptEdits \
--visible
What shows up in the window:
$ claude --resume d63be319-433a-4ae1-8a1c-a8a978e53d89 -p '...' --output-format text --permission-mode acceptEdits
cwd: /home/lenovo/Documentos/free-code/free-code
Saved successfully to `/home/lenovo/Documentos/free-code/free-code/resumen-....md` ...
--- done (exit 0) -- press Enter to close ---
Only --output-format stream-json streams token by token. text
(like json) prints everything at once when the turn is done — that's why
the window sits blank while Claude works and dumps it all at the end. Without the
press Enter to close pause, the window would close itself the instant
it's done, before you'd have time to read it; that's why visible mode always waits
for your Enter before closing, unlike hidden mode.
That pause has a consequence: the event stays "in progress" in the broker until you
close the window. Since runs are serialized per workdir (see the option
table in Registering a hook), if you leave the window
open the next event for that same workdir will queue up until you close it.
Only gnome-terminal is supported for now (Linux/GNOME). If it isn't
installed, it falls back to the usual hidden mode automatically without breaking the
hook.
The base case already covered above: trigger mode, authentication via X-Webhook-Secret.
awb add ci-failures --trigger --workdir /home/lenovo/projects/my-repo \
--prompt-template 'CI build failed:\n\n{{payload}}\n\nInvestigate the cause.'
curl -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret from awb add>" \
-d '{"branch":"main","step":"npm test","exitCode":1}'
A Flowise flow that fires often shouldn't spin up a Claude session for every single
event — with --queue the event is just persisted in SQLite (see
Current limitations on how that queue is read today):
awb add flowise --queue --consumer queue
curl -X POST http://127.0.0.1:8890/hook/flowise \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret from awb add>" \
-d '{"flow":"lead-scoring","result":"qualified","leadId":"8213"}'
awb events flowise # confirm it got stored, status "pending"
With --hmac-secret, instead of sending the secret in a header you use it to
sign the raw body and send the signature in X-Signature: sha256=<hex>:
awb add deploys --trigger --hmac-secret 'a-long-random-secret' \
--workdir /home/lenovo/projects/my-repo
BODY='{"env":"prod","status":"failed"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'a-long-random-secret' | sed 's/^.* //')
curl -X POST http://127.0.0.1:8890/hook/deploys \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIG" \
-d "$BODY"
This awb add skips --prompt-template on purpose, to show
what happens if you omit it: the broker uses the default template
"Incoming webhook event for '{{hook}}':\n\n{{payload}}". With the body
above, the prompt Claude receives ends up being:
Incoming webhook event for 'deploys':
{
"env": "prod",
"status": "failed"
}
For a real use case you'll almost always want your own
--prompt-template (telling Claude what to investigate or do), like in
the other examples in this guide.
GitHub signs its webhooks with X-Hub-Signature-256, not
X-Signature. If you want to point a GitHub webhook straight at this broker
today, you need something in between to rename that header (or relay the event with a
script) — there's no native support for GitHub's header name yet.
awb test (no need to hand-build curl)# brand-new session (no sessionId)
awb test ci-failures --body '{"log":"npm test exit 1"}'
# resumes an existing session (a claude uuid for --runner claude,
# a .jsonl path for --runner free-code)
awb test ci-failures --body '{"log":"npm test exit 1"}' \
--session-id d63be319-433a-4ae1-8a1c-a8a978e53d89
The result of every invocation (exact command, the agent's stdout/stderr) is saved at ~/.agent-webhook-bridge/logs/<hook>-<timestamp>.log.
awb list # registered hooks, mode, URL, secret
awb url ci-failures # detail for one hook
awb events # most recent events received and their status (pending/delivered/failed)
awb events ci-failures
127.0.0.1 — not exposed on the network by default.secret nor hmacSecret configured never authenticates any request.cloudflared/ngrok) and prefer hmacSecret over a plain shared secret.events.db — if the external source can include tokens or secrets in the body (CI logs, for example), keep that in mind before sharing that database.This is phase 1 of the roadmap (see PLAN.md at the repo root): broker + spawn adapter — the spawn adapter supports claude and free-code; the queue/MCP consumer is phase 2.
queue mode persists events in SQLite, but there's no MCP adapter yet (poll_events/wait_for_event) to read them from a session — that's phase 2.systemd/launchd unit or installer: awb start runs in the foreground and you have to supervise it yourself (or with pm2, tmux, etc.) if you want it to survive a reboot.free-code is the second runtime the spawn adapter knows how to wake. It's a full-featured coding agent with its own CLI (free-code), a -p/--print headless mode, and a --mode json NDJSON event stream — the adapter turns that stream into the same {result, session_id} envelope the claude adapter produces, so from the broker/hub side the two are interchangeable.
Register a hook that spawns free-code instead of claude with --runner free-code (or --consumer spawn:free-code):
awb add fc-worker --runner free-code \
--workdir ~/agentmesh-sandbox \
--prompt-template 'You are an AgentMesh agent. Incoming task:\n\n{{payload}}\n\nDo the task and answer with the final result.'
Everything else works the same: --secret/--hmac-secret for auth, callbackUrl in the body to get the result back, --visible for a live terminal window. The only differences from a --runner claude hook are:
.jsonl path, kept under ~/.agent-webhook-bridge/sessions/<hook>/. The callback's session_id is that path; send it back as the sessionId header to continue. (See New session vs. resuming.)--permission-mode is mapped to --tools, not forwarded as a flag (free-code has no --permission-mode). See the table in Permissions in headless mode.--no-extensions --no-skills --no-prompt-templates --no-themes --no-rag-server so a sandbox workdir with untrusted job input can't make free-code discover and run code from local skills/extensions it finds there.This is the runtime the AgentMesh concept paper had in mind for the single-operator bridge, and it's what the demo agent in PLAN.md §4.3 maps to once you swap --runner claude for --runner free-code.
| Command | What it does |
|---|---|
awb start | Runs the broker in the foreground. |
awb add <name> [options] | Registers a new hook (see Registering a hook). --runner <claude|free-code> picks which CLI a trigger hook spawns. |
awb rm <name> | Removes a hook. |
awb list | Lists all registered hooks. |
awb url <name> | Shows the URL and auth header for a hook. |
awb events [name] | Shows the most recently received events (and their status) from SQLite. |
awb test <name> [--body <json>] [--session-id <id>] | Fires a real event against the running broker, with or without sessionId (a claude uuid or a free-code .jsonl path). |
Un broker HTTP independiente que recibe webhooks externos (CI, GitHub, Flowise…) y
despierta a un agente de código — Claude Code o free-code — ya sea como una sesión
completamente nueva o reanudando una existente — sin que el agente tenga que estar
corriendo de antemano. El adaptador de spawn soporta dos runtimes:
Claude Code (el por defecto) y free-code; ambos
comparten el mismo protocolo de hook, así que el broker y quienes llaman se mantienen
agnósticos al runtime — solo eliges qué CLI genera un hook con --runner.
node:sqlite (sin dependencias nativas) y ejecuta los archivos .ts directamente — no hay paso de build.claude y/o free-code en el PATH de la máquina donde corre el broker. Un hook genera el que su --runner seleccione (por defecto claude), así que solo necesitas el/los binario/s que realmente uses — free-code es opcional, instálalo solo si un hook va a usar spawn:free-code.Todavía no está publicado en npm: se usa directamente desde el checkout del repositorio.
cd agent-webhook-bridge
npm install # opcional -- solo trae @types/node para el editor,
# no hace falta para ejecutar nada
Para tener el comando awb disponible desde cualquier carpeta:
npm link # crea el symlink global "awb" -> ./cli/awb.ts
Si prefieres no enlazarlo globalmente, cada comando awb … de esta guía funciona igual como node cli/awb.ts … ejecutado desde la carpeta del repositorio.
awb start
Corre en primer plano y escucha en 127.0.0.1:8890 por defecto (elegido a propósito para no chocar con el webhook-receiver de free-code, que usa 8787-8806).
La configuración (hooks registrados) vive en ~/.agent-webhook-bridge/hooks.json, la cola de eventos en ~/.agent-webhook-bridge/events.db (SQLite), y los logs de cada invocación de Claude en ~/.agent-webhook-bridge/logs/.
Para usar una ubicación distinta (pruebas, múltiples instancias), define AWB_HOME=/tu/ruta antes de cualquier comando awb.
El broker vuelve a leer hooks.json en cada solicitud, así que puedes registrar o quitar hooks con awb add/awb rm mientras sigue corriendo, sin necesidad de reiniciar.
awb add ci-failures \
--trigger \
--workdir /path/to/your/repo \
--prompt-template 'A CI build failed. Log:\n\n{{payload}}\n\nInvestigate the cause.'
La salida devuelve la URL local y el secreto que debes configurar en el sistema externo:
Hook 'ci-failures' [trigger] consumers=spawn:claude
Local URL: http://127.0.0.1:8890/hook/ci-failures
Header: X-Webhook-Secret: 76171625f1dbab4647623cb7f6c99e18...
Workdir: /path/to/your/repo
Prompt: A CI build failed. Log:\n\n{{payload}}\n\nInvestigate the cause.
| Opción | Qué hace |
|---|---|
--trigger / --queue | Modo de entrega. trigger (por defecto) dispara el adaptador de spawn en cuanto llega el evento. queue solo lo persiste en SQLite, para un futuro adaptador de lectura (ver Limitaciones actuales). |
--runner <claude|free-code> | Qué CLI genera un hook de trigger. Por defecto: claude. Atajo para --consumer spawn:claude / --consumer spawn:free-code; un --consumer explícito tiene prioridad. Los dos adaptadores comparten el mismo protocolo de hook (secret, callbackUrl, sessionId), así que el broker y quienes llaman no cambian — solo cambia el binario que se genera. Ver adaptador free-code para las diferencias de sesión/permisos. |
--consumer <c> | Repetible. Por defecto: spawn:claude para trigger (o spawn:free-code con --runner free-code), queue para queue. Usa spawn:free-code para despertar a free-code en lugar de Claude Code. |
--prompt-template <text> | Plantilla para el prompt que recibe el agente generado. {{payload}} se reemplaza con el cuerpo del evento (formateado como JSON si no es texto plano); {{hook}} con el nombre del hook. |
--workdir <dir> | cwd del proceso generado. También es la clave que serializa las ejecuciones: dos eventos con el mismo workdir nunca corren en paralelo sobre el mismo repositorio. |
--secret <s> / --hmac-secret <s> | Autenticación. Si no proporcionas ninguno, se genera uno aleatorio. Con --hmac-secret quien llama firma el cuerpo crudo en lugar de enviar el secreto en un header. |
--permission-mode <mode> | Para --runner claude se pasa tal cual como el --permission-mode de claude (acceptEdits, auto, bypassPermissions, manual, dontAsk, plan). Para --runner free-code se mapea al flag --tools de ese adaptador (ver Permisos en modo headless). Sin esto, las ejecuciones headless no pueden escribir ni editar nada. |
--visible | Ejecuta el agente generado (claude o free-code) en una ventana visible de gnome-terminal en lugar de oculta (ver Modo visible). |
curl lo defines tú, no es un formato fijo
El bridge no espera ningún campo en particular en el cuerpo — ni log, ni
branch, nada. Tú decides qué envía tu sistema externo (o qué escribes a
mano para probar); el broker simplemente toma ese cuerpo tal como llegó y lo
coloca donde sea que aparezca {{payload}} en tu --prompt-template.
No hay sustitución campo por campo — es un único bloque de texto.
Con el hook de arriba, esta solicitud:
curl -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret>" \
-d '{"branch":"main","step":"npm test","exitCode":1}'
termina construyendo exactamente este prompt, que es lo que claude -p recibe como argumento:
A CI build failed. Log:
{
"branch": "main",
"step": "npm test",
"exitCode": 1
}
Investigate the cause.
Claude recibe ese JSON como texto plano dentro del prompt y lo interpreta por sí
mismo — no hay un parseo especial de branch/step/exitCode
del lado del broker. Por eso los distintos ejemplos de esta guía tienen cuerpos de
formas diferentes ({"log": "…"}, {"flow": "…"}, {"branch": "…"}):
no es que cada uno siga un formato obligatorio, es que cada uno simula lo que enviaría
un sistema distinto (un test runner, un flujo de Flowise, un paso de CI). Tú decides
la forma del cuerpo de tu propio llamador, y escribes el --prompt-template
para darle sentido a esa forma.
Si Content-Type no es application/json o el cuerpo no es
JSON válido, {{payload}} igual se reemplaza, solo que con el texto crudo
tal cual — el texto plano en -d también funciona bien, no tiene que ser JSON.
sessionId)
Para hooks con un consumidor spawn:*, el adaptador decide cómo lanzar al
agente según un único header en la solicitud entrante. Ambos adaptadores usan el mismo
header (sessionId), así que el protocolo de quien llama es idéntico
independientemente del --runner — solo difiere el valor: un uuid
de sesión de Claude Code para claude, una ruta .jsonl para
free-code.
--runner claude (por defecto)Para que --resume encuentre la sesión, el --workdir del hook tiene que coincidir con el directorio del proyecto donde se creó originalmente esa sesión — las sesiones de Claude Code están ligadas a un directorio de trabajo.
--resume <id> sin -p/--print abre el selector interactivo de Claude Code. En un proceso generado sin TTY eso se queda colgado para siempre — por eso el adaptador siempre agrega -p también en la rama de reanudación, no solo en la de nueva sesión.
--runner free-codefree-code reanuda por ruta de archivo .jsonl, no por un uuid. El adaptador guarda cada archivo de sesión bajo ~/.agent-webhook-bridge/sessions/<hook>/, y el session_id que devuelve en el callback es esa ruta — así que para continuar una conversación solo la envías de vuelta como header sessionId, exactamente igual que con claude:
Un header sessionId que apunte fuera de ~/.agent-webhook-bridge/sessions/ se trata como no confiable (salvaguarda contra path-traversal) y en su lugar inicia una sesión nueva — el broker nunca abre una ruta arbitraria suministrada por quien llama.
El --mode json de free-code no es un único envelope como sí lo es el --output-format json de claude: emite un flujo de eventos NDJSON (una línea de cabecera de sesión, luego un evento por mensaje/turno/herramienta). El adaptador consume ese flujo y lo recompone en el mismo envelope {result, session_id} que produce el adaptador de claude, así que el cuerpo del callback y el hub de AgentMesh ven una forma uniforme desde cualquiera de los dos runtimes.
sessionId que necesito enviar?
El broker y Claude no lo inventan: es quien llama (tu script,
tu flujo de Flowise, tu pipeline de CI) quien tiene que conocerlo y enviarlo como un
header HTTP literal sessionId: <uuid> en el POST. Tres
formas de obtener ese UUID:
--output-format json devuelve un campo session_id en su
salida JSON — incluida la que inicia claude -p sin reanudar. Guárdalo
la primera vez y reutilízalo en las siguientes llamadas al mismo hook para seguir
esa misma conversación.
<sessionId>.jsonl dentro de
~/.claude/projects/<tu-proyecto-codificado>/ (una carpeta por
proyecto, con guiones en lugar de /). También lo verás en el selector
/resume de Claude Code.
~/.agent-webhook-bridge/logs/<hook>-<timestamp>.log, con
la respuesta completa de Claude — el session_id usado también está ahí.
Ejemplo de capturar el id en la primera llamada y reutilizarlo en la segunda:
# 1) first call: no sessionId, starts a brand-new session.
curl -s -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <your secret>" \
-d '{"log":"npm test exit 1"}'
# 2) read the session_id from that run's most recent log for the hook
SID=$(ls -t ~/.agent-webhook-bridge/logs/ci-failures-*.log | head -1 \
| xargs cat | grep -o '"session_id":"[^"]*"' | cut -d'"' -f4)
# 3) second call to the same hook, now resuming that session
curl -s -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <your secret>" \
-H "sessionId: $SID" \
-d '{"log":"same build still failing, second attempt"}'
El flujo completo funciona igual para cualquiera de los dos runtimes — el valor de
SID simplemente difiere, un uuid para claude y una ruta .jsonl
para free-code. Para --runner free-code incluso puedes saltarte el grep del
log: el cuerpo del callback de la primera llamada ya trae session_id como
esa ruta .jsonl (igual que el callback de claude), así que captúralo ahí
si estás usando callbackUrl.
Una ejecución generada no tiene terminal. Qué ocurre cuando el prompt le pide al agente escribir o editar un archivo depende del runner:
--runner claude (por defecto): Claude Code deniega automáticamente Write/Edit/Bash cuando no hay nadie para aprobar el permiso, y explica que no pudo hacerlo. Esto ocurre incluso al reanudar una sesión que has usado interactivamente sin problemas — el permiso no se "traslada" a una nueva ejecución headless.--runner free-code: free-code no tiene denegación automática en runtime; en su lugar el adaptador acota lo que el agente puede intentar eligiendo su set de flags --tools a partir del --permission-mode del hook (ver la tabla de abajo). Con el valor por defecto (sin --permission-mode) corre en solo-lectura, así que el agente literalmente no tiene una herramienta Write/Edit/Bash que invocar — responde solo a partir de lo que lee.Log real de un hook de claude sin --permission-mode al que se le pidió guardar un archivo:
"result": "I still don't have permission to write the file -- the system requires
someone to approve the write action from the interactive session ...",
"permission_denials": [{ "tool_name": "Write", "tool_input": { "file_path": "..." } }]
La solución es declarar --permission-mode al registrar el hook:
awb rm resume
awb add resume --trigger \
--workdir /home/lenovo/Documentos/free-code/free-code \
--permission-mode acceptEdits
Con eso, la misma solicitud termina escribiendo el archivo de verdad:
"result": "Saved to `/home/lenovo/Documentos/free-code/free-code/resumen-sesion-flowise-webhook.md` (root of the working directory)."
| Modo | Qué autoriza (claude) | a qué --tools de free-code se mapea |
|---|---|---|
| (sin definir) | Deniega automáticamente Write/Edit/Bash — el agente responde pero no puede mutar. | read,grep,find,ls (solo lectura). El valor por defecto seguro de AgentMesh. |
acceptEdits | Auto-aprueba Write/Edit de archivos. Este es el que encaja con la mayoría de los hooks de automatización — no habilita nada más. | read,edit,write,grep,find,ls (sin bash). |
bypassPermissions | Se salta todas las verificaciones de permisos, incluido Bash arbitrario. Mismo riesgo que --dangerously-skip-permissions: solo tiene sentido en un sandbox aislado, no en tu máquina con tus repositorios reales. | read,bash,edit,write,grep,find,ls (completo, incl. bash). Mismo perfil de riesgo. |
dontAsk | Probado y no autoriza Write/Bash a pesar del nombre -- en una ejecución headless real igual denegó la escritura, igual que dejarlo sin definir. No confíes en él para habilitar ediciones. | se mapea al set completo como bypassPermissions — para free-code, donde no hay un prompt en runtime que contestar, dontAsk significa "no lo gates", así que se trata como acceso completo. Mismo riesgo. |
auto | Otro modo de claude, no probado aquí; ver claude --help. | se mapea al set completo como bypassPermissions. |
plan / manual | Otros modos de claude que piden confirmación; una ejecución generada no tiene TTY para confirmar. | read,grep,find,ls (solo lectura) — no hay nadie para confirmar un prompt. |
permissionMode se deja sin definir por defecto en un
hook nuevo — tienes que pedirlo explícitamente. La razón: en cuanto un hook puede
escribir sin preguntar, cualquiera que conozca el secreto del hook puede hacer que
el agente generado escriba archivos en ese workdir con el prompt que
quiera. Úsalo solo en hooks donde confíes en la fuente del evento y en lo que el
--prompt-template pueda terminar pidiéndole hacer al agente.
Por defecto, una ejecución generada no muestra nada — el comando corre oculto y todo
su stdout/stderr va directo al log. Con --visible, en cambio, se abre una
ventana de gnome-terminal que muestra el comando exacto y la respuesta:
awb add resume --trigger \
--workdir /home/lenovo/Documentos/free-code/free-code \
--permission-mode acceptEdits \
--visible
Lo que aparece en la ventana:
$ claude --resume d63be319-433a-4ae1-8a1c-a8a978e53d89 -p '...' --output-format text --permission-mode acceptEdits
cwd: /home/lenovo/Documentos/free-code/free-code
Saved successfully to `/home/lenovo/Documentos/free-code/free-code/resumen-....md` ...
--- done (exit 0) -- press Enter to close ---
Solo --output-format stream-json transmite token por token.
text (igual que json) imprime todo de golpe cuando el
turno termina — por eso la ventana permanece en blanco mientras Claude trabaja y
vuelca todo al final. Sin la pausa de press Enter to close, la ventana
se cerraría sola en el instante en que termina, antes de que tuvieras tiempo de
leerla; por eso el modo visible siempre espera tu Enter antes de cerrarse, a
diferencia del modo oculto.
Esa pausa tiene una consecuencia: el evento queda "en progreso" en el broker hasta que
cierras la ventana. Como las ejecuciones se serializan por workdir (ver la
tabla de opciones en Registrar un hook), si dejas la
ventana abierta, el siguiente evento para ese mismo workdir quedará en
cola hasta que la cierres.
Por ahora solo se admite gnome-terminal (Linux/GNOME). Si no está
instalado, se recurre automáticamente al modo oculto habitual sin romper el hook.
El caso base ya cubierto arriba: modo trigger, autenticación vía X-Webhook-Secret.
awb add ci-failures --trigger --workdir /home/lenovo/projects/my-repo \
--prompt-template 'CI build failed:\n\n{{payload}}\n\nInvestigate the cause.'
curl -X POST http://127.0.0.1:8890/hook/ci-failures \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret from awb add>" \
-d '{"branch":"main","step":"npm test","exitCode":1}'
Un flujo de Flowise que dispara con frecuencia no debería levantar una sesión de
Claude por cada evento — con --queue el evento simplemente se persiste en
SQLite (ver Limitaciones actuales sobre cómo se lee
esa cola hoy):
awb add flowise --queue --consumer queue
curl -X POST http://127.0.0.1:8890/hook/flowise \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: <secret from awb add>" \
-d '{"flow":"lead-scoring","result":"qualified","leadId":"8213"}'
awb events flowise # confirm it got stored, status "pending"
Con --hmac-secret, en lugar de enviar el secreto en un header lo usas para
firmar el cuerpo crudo y envías la firma en X-Signature: sha256=<hex>:
awb add deploys --trigger --hmac-secret 'a-long-random-secret' \
--workdir /home/lenovo/projects/my-repo
BODY='{"env":"prod","status":"failed"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'a-long-random-secret' | sed 's/^.* //')
curl -X POST http://127.0.0.1:8890/hook/deploys \
-H "Content-Type: application/json" \
-H "X-Signature: sha256=$SIG" \
-d "$BODY"
Este awb add omite --prompt-template a propósito, para
mostrar qué pasa si lo omites: el broker usa la plantilla por defecto
"Incoming webhook event for '{{hook}}':\n\n{{payload}}". Con el cuerpo
de arriba, el prompt que recibe Claude termina siendo:
Incoming webhook event for 'deploys':
{
"env": "prod",
"status": "failed"
}
Para un caso de uso real casi siempre querrás tu propio
--prompt-template (indicándole a Claude qué investigar o hacer), como
en los demás ejemplos de esta guía.
GitHub firma sus webhooks con X-Hub-Signature-256, no con
X-Signature. Si quieres apuntar un webhook de GitHub directo a este
broker hoy, necesitas algo intermedio que renombre ese header (o que reenvíe el evento
con un script) — todavía no hay soporte nativo para el nombre del header de GitHub.
awb test (sin necesidad de armar curl a mano)# brand-new session (no sessionId)
awb test ci-failures --body '{"log":"npm test exit 1"}'
# resumes an existing session (a claude uuid for --runner claude,
# a .jsonl path for --runner free-code)
awb test ci-failures --body '{"log":"npm test exit 1"}' \
--session-id d63be319-433a-4ae1-8a1c-a8a978e53d89
El resultado de cada invocación (comando exacto, stdout/stderr del agente) se guarda en ~/.agent-webhook-bridge/logs/<hook>-<timestamp>.log.
awb list # registered hooks, mode, URL, secret
awb url ci-failures # detail for one hook
awb events # most recent events received and their status (pending/delivered/failed)
awb events ci-failures
127.0.0.1 — no se expone en la red por defecto.secret ni hmacSecret configurado nunca autentica ninguna solicitud.cloudflared/ngrok) y prefiere hmacSecret sobre un secreto compartido plano.events.db — si la fuente externa puede incluir tokens o secretos en el cuerpo (logs de CI, por ejemplo), ten eso en cuenta antes de compartir esa base de datos.Esta es la fase 1 de la hoja de ruta (ver PLAN.md en la raíz del repositorio): broker + adaptador de spawn — el adaptador de spawn soporta claude y free-code; el consumidor de queue/MCP es la fase 2.
queue persiste eventos en SQLite, pero todavía no hay un adaptador MCP (poll_events/wait_for_event) para leerlos desde una sesión — eso es la fase 2.systemd/launchd ni instalador: awb start corre en primer plano y tienes que supervisarlo tú mismo (o con pm2, tmux, etc.) si quieres que sobreviva a un reinicio.free-code es el segundo runtime que el adaptador de spawn sabe despertar. Es un agente de código completo con su propio CLI (free-code), un modo headless -p/--print, y un flujo de eventos NDJSON con --mode json — el adaptador convierte ese flujo en el mismo envelope {result, session_id} que produce el adaptador de claude, así que del lado del broker/hub los dos son intercambiables.
Registra un hook que genere a free-code en lugar de claude con --runner free-code (o --consumer spawn:free-code):
awb add fc-worker --runner free-code \
--workdir ~/agentmesh-sandbox \
--prompt-template 'You are an AgentMesh agent. Incoming task:\n\n{{payload}}\n\nDo the task and answer with the final result.'
Todo lo demás funciona igual: --secret/--hmac-secret para autenticación, callbackUrl en el cuerpo para recibir el resultado, --visible para una ventana de terminal en vivo. Las únicas diferencias con un hook --runner claude son:
.jsonl, guardadas bajo ~/.agent-webhook-bridge/sessions/<hook>/. El session_id del callback es esa ruta; envías de vuelta como header sessionId para continuar. (Ver Nueva sesión vs. reanudar.)--permission-mode se mapea a --tools, no se reenvía como flag (free-code no tiene --permission-mode). Ver la tabla en Permisos en modo headless.--no-extensions --no-skills --no-prompt-templates --no-themes --no-rag-server para que un workdir sandbox con input de job no confiable no pueda hacer que free-code descubra y ejecute código de skills/extensiones locales que encuentre ahí.Este es el runtime que el paper conceptual de AgentMesh tenía en mente para el bridge de operador único, y es a lo que se mapea el agente demo de PLAN.md §4.3 una vez que cambias --runner claude por --runner free-code.
| Comando | Qué hace |
|---|---|
awb start | Ejecuta el broker en primer plano. |
awb add <name> [options] | Registra un nuevo hook (ver Registrar un hook). --runner <claude|free-code> elige qué CLI genera un hook de trigger. |
awb rm <name> | Elimina un hook. |
awb list | Lista todos los hooks registrados. |
awb url <name> | Muestra la URL y el header de autenticación de un hook. |
awb events [name] | Muestra los eventos recibidos más recientemente (y su estado) desde SQLite. |
awb test <name> [--body <json>] [--session-id <id>] | Dispara un evento real contra el broker en ejecución, con o sin sessionId (un uuid de claude o una ruta .jsonl de free-code). |