Driving Glorp & Glorp Garage
You are an agent working with Glorp programmatically — not the Glorp TUI user. This skill tells you how to start a Garage, create sessions, run coding agents inside sandboxes, and collect their results, safely and without deadlocking.
Glorp is a coding agent (read/write/edit/bash/glob/grep/ls/web_fetch, subagents, a mesh for fan-out). You can run it three ways:
| Mode | Command | Use when |
|---|---|---|
| TUI | glorp |
A human is driving interactively. Not your path. |
| Headless one-shot | glorp -p "…" |
One prompt, one workspace, exits when done. |
| Garage | glorp garage |
You need many concurrent, long-lived, remotely-driven sessions. This is your path for orchestration. |
Don't conflate two subsystems. "Glorp Garage" (this skill) is the multi-session HTTP/WS control plane. It is not the
station-signalfleet runner (a separate async fan-out framework). Garage was formerly named "Station", so older notes may use that name.
Two surfaces — pick the altitude you need
Garage exposes the same runtime at two levels. Choose deliberately:
| Surface | You manage | Reach for it when |
|---|---|---|
| Sessions (most of this skill) | sessions, workspaces, permission modes, the event stream | you want fine-grained control — multi-turn conversations, live streaming, custom orchestration |
| Task API (jump) | one object — submit an input, poll a result |
you just want a typed job done (a deck, a video, a PR fix) and a deliverable back, with no session/stream bookkeeping |
Tasks are built on top of sessions (a task is a worker session under the hood), so the two coexist — you can start with the Task API and drop to the session layer only if you outgrow it.
Core model: sessions, workspaces, sandboxes
- A session is one running Glorp agent with its own conversation, plan, tasks, and
permission state. State:
provisioning → idle ⇄ busy → (error) → destroyed. - A workspace is the directory the agent reads and edits. Two kinds:
- Caller-supplied workspace — an existing host directory you point at
(
"workspace": "/abs/path"). Garage never deletes these. - Sandbox — a workspace Garage provisions itself under
workspaceRoot(default<data-dir>/workspaces). Created when you omitworkspace, or when you provision from atemplate. Garage may clean these up. Sandboxes are the safe target forbypass/autounattended runs and disposable work.
- Caller-supplied workspace — an existing host directory you point at
(
The distinction matters for cleanup: DELETE /sessions/:id?workspace=true removes the
folder only if it is a Garage-provisioned sandbox under workspaceRoot that no other
session references. Caller-supplied and shared workspaces are always kept. So you can
safely cascade-delete a sandbox without risking a user's real repo.
Set up glorp
Needs Bun ≥ 1.3. Build the binary (or run from source), then give it a model provider key:
git clone https://github.com/porkytheblack/glorp && cd glorp
bun install
bun run build # → dist/glorp (single binary); or: bun run src/cli.ts
export ANTHROPIC_API_KEY=sk-ant-… # or OPENAI_API_KEY / OPENROUTER_API_KEY / GEMINI_API_KEY / GROQ_API_KEY
The provider key is what the agents run on; the Garage API key (below) is separate and
gates access to the control plane. Persistent config lives in a garage.json under the
data dir (CLI flags override it). Full setup guide: docs/garage-usage.md.
Start a Garage
glorp garage # REST + WS on http://127.0.0.1:4271 (loopback, auth OFF)
glorp garage --host 0.0.0.0 # remote-reachable → auth REQUIRED automatically
glorp garage --workspace-root /srv/sandboxes # where sandboxes get provisioned
- Loopback (
127.0.0.1) binds are open; any non-loopback bind requires an API key. Force either way withGLORP_GARAGE_AUTH=required|off. - Credentials for the agents themselves come from
~/.glorpconfig or env vars (ANTHROPIC_API_KEY,OPENAI_API_KEY, …). - Persistent config: a
garage.jsonin the data dir (CLI flags override it). - Every endpoint is served at both
/api/v1/…(stable) and the bare root. Prefer/api/v1for anything durable. - Back-compat:
glorp stationstill works as a hidden alias forglorp garage, and the legacyGLORP_STATION_*env vars and astation.jsonconfig file are still honored.
Auth (non-loopback)
glorp garage keys add ci-bot --scopes admin # prints the raw glsk_… key ONCE — capture it
glorp garage keys list
glorp garage keys revoke <id>
Send it as Authorization: Bearer glsk_… on REST, or ?api_key=glsk_… on the WebSocket
(browsers can't set WS headers). Scopes: admin > run > read. /health is always open.
Recommended: the typed SDK
Prefer @porkytheblack/glorp-client over raw
curl when you're in a TS/JS context — it handles framing, reconnects, and errors.
import { configure, run } from "@porkytheblack/glorp-client";
configure({ endpoint: "http://127.0.0.1:4271", apiKey: "glsk_…" }); // or env GLORP_ENDPOINT/GLORP_API_KEY
const h = await run({
workspace: "/srv/projects/acme", // existing dir; OR omit for a fresh sandbox; OR { workspaceId }
prompt: "Add a /health endpoint and a test for it.",
// permissionMode defaults to "auto" so unattended runs don't deadlock on a prompt
});
const { text, status } = await h.result(); // blocks until the turn finishes
Run handle: h.sessionId, await h.status(), for await (const ev of h.events()) (live
stream), await h.result({ timeoutMs }), await h.abort().
Full client for multi-turn / admin work:
import { createClient } from "@porkytheblack/glorp-client";
const glorp = createClient({ endpoint, apiKey });
const ws = await glorp.workspaces.create("/srv/projects/acme");
const s = await glorp.sessions.createInWorkspace(ws.id, { permissionMode: "auto" });
const { text } = await glorp.sessions.sendMessageAndWait(s.id, "Refactor the auth module.");
Non-2xx throws a typed GlorpRemoteError with .status / .code (e.g. 401 = bad key).
Raw REST (when you can't use the SDK)
Session lifecycle, the loop you'll write most often:
BASE=http://127.0.0.1:4271/api/v1
H='content-type: application/json'
# (add -H "authorization: Bearer glsk_…" on any non-loopback Garage)
# 1. Create a session. Omit "workspace" to get a fresh SANDBOX under workspaceRoot.
ID=$(curl -s -X POST $BASE/sessions -H "$H" \
-d '{"permissionMode":"auto"}' | jq -r .id)
# 2a. Fire-and-forget (202): run in background, then watch WS or poll.
curl -s -X POST $BASE/sessions/$ID/messages -H "$H" \
-d '{"text":"Run the test suite and fix any failures"}'
# 2b. OR block until the turn finishes (best for CI / scripts):
curl -s -X POST $BASE/sessions/$ID/messages -H "$H" \
-d '{"text":"Run the tests and fix failures","wait":true,"timeout_ms":600000}' | jq .
# 3. Poll status / collect the latest answer without re-sending:
curl -s $BASE/sessions/$ID | jq '{state,busy,turn_count}'
curl -s $BASE/sessions/$ID/result | jq '{status,busy,text,error}'
# 4. Abort a runaway turn:
curl -s -X POST $BASE/sessions/$ID/abort
# 5. Tear down. ?workspace=true also rm's the folder IFF it's a disposable sandbox.
curl -s -X DELETE "$BASE/sessions/$ID?workspace=true"
Key endpoints (full table in docs/garage-usage.md,
contract in docs/openapi.yaml):
| Method | Path | Purpose |
|---|---|---|
POST |
/sessions / /workspaces/:id/sessions |
Create session (body below) |
GET |
/sessions · /sessions/:id |
List · detail (poll state/busy here) |
POST |
/sessions/:id/messages |
Send a prompt (wait for sync) |
GET |
/sessions/:id/result |
Latest answer + run status, one call |
POST |
/sessions/:id/abort |
Stop the current turn |
DELETE |
/sessions/:id[?workspace=true] |
Destroy (optionally rm the sandbox) |
GET/POST/DELETE |
/sessions/:id/files[/:path] |
The uploads/ file exchange |
POST/DELETE |
/sessions/:id/credentials |
Per-session key (in-memory only) |
GET/POST |
/workspaces[…] |
Register / list / inspect workspaces |
GET |
/templates · /health |
Setup templates · liveness (open) |
POST /sessions body (all optional):
{
"workspace": "/home/dev/my-app", // existing dir; OMIT → fresh sandbox under workspaceRoot
"workspaceId": "ws_…", // OR attach to a registered workspace
"template": "next-app", // OR provision a sandbox from a template
"params": { "repo": "me/my-repo" },// template interpolation values
"provider": "anthropic", "model": "claude-sonnet-4-20250514",
"permissionMode": "normal", // normal | auto | bypass (see below)
"credentials": { "provider":"anthropic", "apiKey":"sk-ant-…", "model":"…" } // never persisted
}
Sandboxes from templates
Templates provision a sandbox before the agent starts — clone a repo, install deps,
drop in config. Put JSON files in <data-dir>/templates/; the filename is the template name.
// ~/.glorp/templates/next-app.json
{
"description": "Clone a repo and install deps",
"steps": [
{ "type": "git-clone", "repo": "https://{env:GH_TOKEN}@github.com/{param:repo}.git", "ref": "main" },
{ "type": "shell", "command": "bun install" },
{ "type": "copy", "from": "/home/dev/templates/glorp.json", "to": "glorp.json" }
]
}
- Step types:
git-clone(repo, optionaldest/ref),shell(command),copy(from,to). - Interpolation:
{param:NAME}from the requestparams,{env:VAR}from Garage's env. Interpolated values are scrubbed from error messages (secret-safe). - Steps run sequentially. On any failure the sandbox is torn down and creation fails — you never get a half-provisioned workspace. (Only a Garage-created dir is removed; a pre-existing caller dir is left intact.)
curl -s -X POST $BASE/sessions -H "$H" \
-d '{"template":"next-app","params":{"repo":"me/my-repo"},"permissionMode":"bypass"}'
Permission modes — pick deliberately
Unattended runs deadlock if a tool asks for permission and nobody answers.
| Mode | Behavior | Use for |
|---|---|---|
normal |
Prompts on risky ops; you must resolve each slot | A human/agent actively watching the stream |
auto |
Auto-approves safe ops, prompts on risky ones | Default for unattended runs (SDK run() uses this) |
bypass |
No prompts at all | Disposable sandboxes only — never a user's real repo |
If you stay in normal, you must answer permission slots as they arrive:
- REST:
POST /sessions/:id/slots/:slotId→{ "action": "approve" }(ordeny/resolve+value/reject+reason). - WS command:
{ "type": "resolve_permission", "slot_id":"…", "allow": true }. - Or in
waitmode pass"auto_approve": trueto clear prompts for that one turn.
File exchange (uploads/)
Each session has an uploads/ folder inside its workspace — the hand-off point. Drop inputs
in for the agent; the agent writes deliverables (.pptx, .zip, exports) back for you.
Paths are confined to the folder (.. traversal → 400).
curl -s -F file=@./brief.pdf $BASE/sessions/$ID/files # upload
curl -s $BASE/sessions/$ID/files | jq . # list
curl -s $BASE/sessions/$ID/files/deck.pptx -o deck.pptx # download
SDK: client.sessions.uploadFile/files/downloadFile/deleteFile.
Streaming over WebSocket
Connect GET /sessions/:id/events (auth via ?api_key=). You get a session_hydrate
snapshot, then a live stream. Each frame: { "sessionId", "seq", "event": {…} }. seq is
monotonic per client — a gap means you missed events; send { "type":"resync" } or
reconnect to re-hydrate. Commands you can push back: send_message, abort,
resolve_permission, set_permission_mode, resync. SDK: h.events() / streamSession().
Per-session credentials
POST /sessions/:id/credentials swaps a session onto its own provider key; DELETE reverts.
The key is in memory only — never persisted, logged, or returned (responses show provider
- last-4). Re-supply after a Garage restart.
Multi-tenant namespaces
Provision an isolated namespace per user so their sessions/workspaces/sandboxes/
credentials can't see each other (data lives under <dataDir>/namespaces/<id>/,
sandboxes under <workspaceRoot>/<id>/). Requires auth on. Admin-only control plane:
# Provision + mint a tenant-bound key (raw key returned once)
curl -sX POST $EP/api/v1/namespaces -H "authorization: Bearer $ADMIN" \
-H 'content-type: application/json' -d '{"name":"acme"}' # -> ns_acme
curl -sX POST $EP/api/v1/namespaces/ns_acme/keys -H "authorization: Bearer $ADMIN" \
-H 'content-type: application/json' -d '{"name":"acme-bot"}' # -> glsk_… (run,read)
# Tenant uses ITS key — every call auto-scopes to ns_acme.
curl -sX POST $EP/api/v1/sessions -H "authorization: Bearer $TENANT" \
-H 'content-type: application/json' -d '{"permissionMode":"auto"}'
# Admin proxies into any namespace via a header (?ns=<id> for WebSocket):
curl -s $EP/api/v1/sessions -H "authorization: Bearer $ADMIN" -H 'x-glorp-namespace: ns_acme'
# Deprovision (revokes keys, stops sessions; ?data=true wipes the subtree).
curl -sX DELETE "$EP/api/v1/namespaces/ns_acme?data=true" -H "authorization: Bearer $ADMIN"
Same flow with the SDK (needs an admin key):
const glorp = createClient({ endpoint, apiKey: ADMIN_KEY });
const ns = await glorp.namespaces.create("acme"); // -> { id: "ns_acme", … }
await glorp.namespaces.createKey(ns.id, "acme-bot"); // raw glsk_… returned once
// The tenant then drives its OWN namespace with that key: createClient({ endpoint, apiKey: <it> }).
Requests with no namespace resolve to the built-in default namespace (the legacy
single-tenant layout) — existing setups keep working unchanged. Each namespace can
hold its own model credentials, falling back to the garage's defaults when unset — and
its own template library (see Companions, below).
Per-namespace template libraries. Beyond the garage-global catalog in
<dataDir>/templates/, a namespace can have its own templates two ways: on-disk files
under its subtree (<dataDir>/namespaces/<id>/templates/*.json), and/or its own
companion registry. Resolution is inherit-and-override, newest-wins:
tenant disk > tenant companion > garage disk > garage companion. So a tenant's
GET /templates, /templates/:name, and /tasks/types all reflect its effective
catalog. A namespace with neither its own dir nor companion inherits the garage catalog
unchanged. The companion route is covered next.
Companions
A companion is a small external service Garage talks to for two things it
deliberately doesn't own (full wire spec: docs/companion-service-spec.md):
- Git tokens — it holds the GitHub App key and mints short-lived install tokens; Garage pulls one when a template clone needs auth (never stores it).
- A template registry — it hosts Template v2 documents; Garage only GETs them and provisions workspaces from them. The service can generate/rotate templates however it likes.
Garage only ever issues GETs with static headers, and the companion is optional at
runtime: if it's down, Garage serves the last-known-good catalog and keeps working.
Global companion — one registry for the whole Garage. Set it via env (or garage.json):
export GLORP_GARAGE_TEMPLATE_REGISTRY_URL=https://companion.example/v1/templates
export GLORP_GARAGE_TEMPLATE_REGISTRY_HEADERS='{"authorization":"Bearer <garage-key>"}'
Per-namespace companion — give a tenant its own registry (its own key → its own library) at provision time. This is how each client gets a different catalog:
curl -sX POST $EP/api/v1/namespaces -H "authorization: Bearer $ADMIN" -H 'content-type: application/json' -d '{
"name": "acme",
"template_registry": {
"url": "https://companion.example/v1/templates",
"headers": { "authorization": "Bearer <acme-tenant-key>" }
}
}'
// SDK equivalent — third arg is the per-namespace registry.
await glorp.namespaces.create("acme", undefined, {
url: "https://companion.example/v1/templates",
headers: { authorization: "Bearer <acme-tenant-key>" },
});
- The
urlmust be http(s). Theheaders(the tenant's key) are stored server-side and never returned —GET /namespaces/:idexposes onlytemplate_registry_url. - The tenant's companion templates layer in as
tenant disk > **tenant companion** > garage disk > garage companion, so they beat the garage catalog but yield to the tenant's own on-disk overrides. Everything the tenant reads (/templates,/tasks/types) reflects this. - Companion templates inline their skill files, so a tenant needs no on-disk assets — the whole catalog can live in the companion.
MCP server (for MCP-capable agents)
If you're an MCP client (Claude Desktop/Code, Cursor, a custom orchestrator), you
can drive a Garage as tools instead of raw REST: run
@porkytheblack/glorp-mcp, which wraps the kit and speaks
MCP over stdio (default) or streamable HTTP (--http, POST /mcp).
GLORP_ENDPOINT=$EP GLORP_API_KEY=$KEY npx @porkytheblack/glorp-mcp # stdio
GLORP_ENDPOINT=$EP GLORP_API_KEY=$KEY npx @porkytheblack/glorp-mcp --http # HTTP /mcp
Tools (glorp_*): namespaces (list/get/create/delete, mint_namespace_key,
list_namespace_keys), workspaces (list/create/delete), sessions (run, list,
get, send_message, session_result, abort, destroy), and the agent roster
(list_agents, add_agent, switch_agent, remove_agent). Each session/workspace/
agent tool takes an optional namespace for admin proxying. Admin tools 403
cleanly if the configured key isn't admin. Full guide: docs/mcp.md.
Task API — typed jobs, one object to poll
The black-box surface: hand Garage a job and get a result, without touching
sessions, templates, or the event stream. A task is one object — an input
you submit and a result you poll (or receive via webhook) until it's
completed/failed. A task type is a template name, so the catalog is
self-extending: a new capability is a new template server-side, no app change.
import { createClient } from "@porkytheblack/glorp-client";
const glorp = createClient({ endpoint, apiKey }); // or GLORP_ENDPOINT / GLORP_API_KEY
// 1. Submit — returns immediately; the work runs in the background.
const { id } = await glorp.tasks.create({
type: "slide-deck",
input: { prompt: "A 5-slide deck on our Q3 results" },
// permission_mode defaults to "bypass" (a task's worker runs in a fresh sandbox)
});
// 2. Wait — polls to completion, answering any questions the agent asks.
const task = await glorp.tasks.wait(id, {
onQuestion: (q) => (q.kind === "confirm" ? true : q.options?.[0]?.value ?? ""),
onProgress: (note) => console.log("…", note),
});
// 3. Use the result.
if (task.status === "completed") {
console.log(task.result.summary);
for (const f of task.result.files) { // deliverables
await fs.writeFile(f.path, await glorp.tasks.downloadFile(id, f.path));
}
} else console.error("task failed:", task.error);
Lifecycle (projected live on every read, never stored):
queued → working ⇄ needs_input → completed | failed, with failed reachable
from any state. A defer_start submit adds a staged step —
queued → staged → (upload inputs, then start) → working — so you can attach
input files before the first turn runs.
What makes tasks different from a raw session:
- Deliverable contracts gate completion. A task type whose template declares
a
requireddeliverable never readscompletedon a text reply alone — it staysworkinguntil the agent produces an artifact that satisfies the contract (right file type, exists, passes a structural magic-byte check, passes anyverifycommand). This is the model-independent lever that stops a "make a video" task from handing back a JSON storyboard or finishing with no file. Nudge a stuck task withtasks.message(id, "…"). - A cumulative usage meter rides every read (
task.usage:tokens_in/out/total,cost_usd,cost_known). It's monotonic and survives context compaction, so you can poll it, diff against your last reading, and bill the delta.cost_known: falsemeans an unpriced model was used — treatcost_usdas a floor.
The methods you'll actually call:
| Call | Does |
|---|---|
tasks.types() |
list submittable types + their typed inputs (build a form / validate) |
tasks.create({ type, input:{ prompt, params? }, permission_mode?, callback_url?, defer_start? }) |
submit; returns { id } |
tasks.wait(id, { onQuestion, onProgress }) |
poll to a terminal state, answering questions |
tasks.get(id) / tasks.list() |
read one / all task objects (own the loop yourself if you prefer) |
tasks.answer(id, questionId, value) |
answer a needs_input question (choice→value, confirm→bool, text→string, info→null) |
tasks.message(id, "now fix X") |
follow up; the task keeps its workspace + context and re-delivers |
tasks.files(id) / tasks.downloadFile(id, path) |
list / fetch deliverables (in uploads/) |
tasks.createWithInputs(input, files) |
the files-first path: create deferred, upload into inputs/, start — one call |
tasks.delete(id) |
cancel + remove the session and workspace |
Webhooks (skip polling): pass callback_url at submit time and Garage POSTs
the full task object on every transition into needs_input/completed/failed.
Delivery is fire-and-forget (5s timeout, not retried), so keep tasks.get()
as the source of truth and reconcile on a timer.
Operator note: infra secrets (a render key/URL) are managed params set
once on the host (GLORP_GARAGE_TASK_PARAM_<NAME>=… or taskParams in
garage.json). They're authoritative, applied to every task, and hidden from
tasks.types(), so a submitting app never sees or supplies them. To hand a task
runtime env vars, declare a template env map ({env}/{param}
interpolated, isolated per task).
Raw REST mirrors all of this under /tasks… (Authorization: Bearer <key>,
optional X-Glorp-Namespace). Full walkthrough — happy path, the task object,
input files, answering questions, webhooks, and the REST table — is bundled at
docs/tasks.md.
Gotchas & safety
- Don't
bypassagainst a real repo. Reservebypassfor sandboxes you'll delete. - Default to
autofor anything unattended or your run hangs on the first prompt. - Poll
state/busy(GET /sessions/:id) or stream — don't assume a turn is done. UseGET /sessions/:id/resultto fetch the latest answer without re-prompting. wait:trueneeds a realtimeout_msfor long jobs (e.g. test suites:600000).- Cleanup is guarded:
?workspace=trueonly removes disposable sandboxes; it will not delete a caller-supplied or shared workspace. Safe to call on sandboxes; a no-op-on-folder for real repos. - Capture API keys at creation — the raw
glsk_…is shown exactly once. /healthis unauthenticated — use it for liveness probes.
Where to read more
Reference docs are bundled with this skill under ./docs/:
docs/tasks.md— the Task API integration guide (the black-box typed-job surface)docs/garage-usage.md— full usage guide (CLI, REST, WS, templates)docs/companion-service-spec.md— the companion wire spec (git tokens + template registry)docs/openapi.yaml— the machine-readable contractdocs/remote-orchestration.md— driving Garage remotelydocs/mcp.md— the MCP server (drive Glorp from any MCP agent)docs/glorp-client.md— the typed SDK (@porkytheblack/glorp-client)docs/glorp-mcp.md— the MCP server package (@porkytheblack/glorp-mcp)docs/codebase/garage-internals.md— how Garage works under the hooddocs/codebase/networking.md— server/client/protocol/SDK internals