Extensions
Write per-repo TypeScript that runs inside the forge — capability-gated, with scoped storage, records, event triggers, and reactive hooks.
Extensions are TypeScript modules a repo commits under .garage/extensions/.
The forge bundles them server-side and runs them inside a sandboxed Worker
isolate — reacting to pushes, exposing typed dispatchable tools, owning a
per-repo record store, and reaching the network only through host-mediated
APIs. There is no separate service to deploy: an extension lives in the repo and
runs where the repo lives.
Every host capability an extension touches is gated — file reads, network
egress, storage, deploys — and every binding is scoped by construction to
repo + extension, so cross-repo and cross-extension access is impossible
rather than merely disallowed.
The shape of an extension
An extension is a single default export built with defineExtension:
import { defineExtension, z } from '@thegarage/ext'
export default defineExtension({
description: 'Say hello when dispatched.',
dispatch: true,
capabilities: ['notify'],
input: z.object({ name: z.string().min(1) }),
output: z.object({ message: z.string() }),
async run(ctx, input) {
const message = `hello ${input.name}`
await ctx.garage.notify({ message })
return { message }
},
})
@thegarage/ext is a dev dependency for editor types only — the build
endpoint bundles it server-side, so nothing from the package ships in your
deploy. It also re-exports the real Zod z, so input/output schemas are
full Zod and serialize to JSON Schema for the descriptor.
Lifecycle
Scaffold
garage ext new <name> writes .garage/extensions/<name>.ts plus a
package.json and tsconfig.json wired for @thegarage/ext editor types.
garage ext new hello
npm i # pull in @thegarage/ext for type-checkingAuthor and commit
Edit the module, then commit it to your repo like any other source file.
Extensions are versioned with your code — a branch can carry a different
extension than main.
Build
garage ext build uploads the committed extension tree, bundles it
server-side, runs a capability-less describe pass, stores the bundle in
content-addressed object storage, and writes the committed
*.descriptor.json (capabilities, triggers, schemas, record schema, and
content hashes). You never hand-write a descriptor.
garage ext buildPromote to canonical
garage ext promote <ref> advances the repo’s canonical extension
config to a source ref’s tip. Canonical authority is what governs live runs,
the record schema, and effective capabilities — a feature branch can propose
an extension, but only promotion makes it authoritative.
garage ext promote maingarage ext list (the extensions.list RPC) shows each extension’s
descriptor, whether it is dispatchable, its effectiveCapabilities, and
diagnostics like schemaAhead (a branch whose record schema diverges from
canonical).
Triggers
An extension runs on a push it subscribes to, on explicit dispatch, or both.
export default defineExtension({
on: { push: { branches: ['main'] } }, // run on push to main
dispatch: true, // also invocable as a typed tool
// ...
})
on.push—{ branches?: string[] }selects refs (short or full).pathsglobs are reserved and not yet enforced.dispatch: true— exposes the extension as a typed, invocable procedure across the UI, CLI, agent tools, and MCP.input/outputZod schemas are required for a dispatchable extension; input is validated beforerun.
A dispatchable extension is invoked through the runs.dispatch procedure on the
typed API — { name, extension, input, ref? } — which returns a run view you can
poll for status and logs. Push runs are created automatically when a matching
ref advances.
The run context (ctx)
Every entrypoint receives a host context. Each method is gated by the
extension’s effective capabilities; a missing capability emits a
run.capability.denied receipt and throws.
type ExtensionContext = {
runId: string
repo: string
ref: string // the branch this run executes against
sha: string | null // the commit, when the run has one
log(message: string, data?: unknown): Promise<void>
garage: {
notify(input: { message: string }): Promise<void> // notify
deploy(input?: { project?: string; dryRun?: boolean }): Promise<{ url?: string }> // deploy
readFile(input: { path: string; ref?: string }): Promise<{ content: string }> // repoRead
writeFile(input: { path: string; content: string; message?: string }): Promise<void> // repoWrite
}
kv: {
/* scoped key/value — kvRead / kvWrite */
}
records: {
/* per-repo record store — recordsRead / recordsWrite */
}
net: {
fetch(url: string, init?: RequestInit): Promise<Response> // network
}
}
Capabilities
Capabilities are requested in the descriptor and enforced at every host boundary. The ones that ship today:
| Capability | Unlocks |
|---|---|
repoRead / repoWrite |
ctx.garage.readFile / ctx.garage.writeFile |
kvRead / kvWrite |
ctx.kv reads (get/list) / writes (put/delete) |
recordsRead / recordsWrite |
ctx.records reads (get/list) / writes (create) |
network |
ctx.net.fetch (host-mediated egress) |
notify |
ctx.garage.notify |
deploy |
ctx.garage.deploy |
r2Read, r2Write, ai, broker, and bash:* scopes are reserved in the
vocabulary but not yet wired to host bindings.
Scoped key/value (ctx.kv)
ctx.kv is a host-owned wrapper over one KV namespace. Every key is
transparently scoped to repo:<repoId>:ext:<extensionName>:<key>, so the
extension never holds a raw KV handle and cannot reach another repo’s or
extension’s keys.
await ctx.kv.put('cursor', 'page-2', { expirationTtl: 86_400 }) // kvWrite, optional TTL
const v = await ctx.kv.get('cursor') // kvRead → 'page-2'
const { keys, cursor } = await ctx.kv.list({ prefix: 'issue:' }) // kvRead
await ctx.kv.delete('cursor') // kvWrite
list()returns unscoped keys plus an opaquecursor(nullwhen exhausted); aprefixis applied within the extension’s scope.- A scoped key over 512 bytes is rejected — the scope prefix counts against KV’s key budget.
Use it for cursors, dedupe markers, and loop guards. A common pattern is a
seen:<sha> key with a TTL so a push handler fires once per commit.
Records (ctx.records)
An extension can declare a typed, per-repo record store with defineRecord and
operate on it through ctx.records. The host owns the table, indexes, and
migrations inside an isolated per-repo facet — no raw SQL ever reaches
extension code.
import { defineExtension, defineRecord, z } from '@thegarage/ext'
export default defineExtension({
capabilities: ['recordsRead', 'recordsWrite'],
record: defineRecord({
name: 'issue',
fields: {
title: { kind: 'string', required: true },
status: { kind: 'string', enum: ['open', 'in_progress', 'closed'], default: 'open' },
priority: { kind: 'string', enum: ['P0', 'P1', 'P2', 'P3'], default: 'P2' },
},
indexes: ['status', 'priority'],
}),
dispatch: true,
input: z.object({ title: z.string().min(1) }),
output: z.object({ id: z.string() }),
async run(ctx, input) {
const issue = await ctx.records.create({ title: input.title }) // recordsWrite
const open = await ctx.records.list({ where: { status: 'open' }, limit: 100 }) // recordsRead
await ctx.log(`opened ${issue.id}; ${open.rows.length} now open`)
return { id: issue.id }
},
})
- Field
kindis one ofstring,integer,boolean,json; string fields may declare anenum. list({ where })only accepts indexed fields — an unindexed filter throws. It returns a page of rows plus an opaquecursor.- The store is create/read today (
create,get,list); an update path is landing.
Schema authority is canonical-only
The live table is governed by the canonical descriptor’s record schema. A
feature branch whose record differs is surfaced as schemaAhead on
extensions.list and never reshapes the live store — promotion is what
applies a schema change.
Migrations are handled by the host: additive fields/indexes and enum
expansion auto-apply transactionally; destructive or ambiguous changes
(drops, type changes, unannotated renames) are blocked before any DDL. A
rename is expressed explicitly and becomes a RENAME COLUMN:
"assignee": { "kind": "string", "renamedFrom": "owner" }
Record hooks
An extension that owns a record can react to mutations by exporting
onRecordCreate / onRecordTransition. The host fires the matching hook as a
separate trigger:'record' run.
export default defineExtension({
record: defineRecord({
name: 'task',
fields: {
/* ... */
},
}),
capabilities: ['recordsWrite', 'notify'],
async run(ctx, input) {
return { id: (await ctx.records.create(input)).id }
},
async onRecordCreate(ctx, { record }) {
await ctx.garage.notify({ message: `task ${record.id} opened` })
},
async onRecordTransition(ctx, { prev, next, record }) {
/* fires when a record transitions */
},
})
- Hook flags are derived, not declared. The descriptor’s
record.hooksflags are computed from the exported entrypoints, so a committed descriptor can never claim a hook with no callable handler behind it. - Authority is canonical-only — only the canonical extension that owns the record fires; a branch-ahead hook never runs.
- A hook runs with the extension’s effective capabilities and the branch the
mutation came from as
ctx.ref, soctx.garage.writeFiletargets that branch. - Re-entrancy is guarded. A record mutation made inside a hook does not
re-fire hooks (a
record.hook.suppressedreceipt notes the suppression), so chains can’t form. - Module scope is ephemeral. Hooks run on a warm isolate as a transparent
cold-start optimization — never a state mechanism. Persist state in
ctx.records/ctx.kv; module-level variables do not survive between fires.
Network egress (ctx.net.fetch)
ctx.net.fetch(url, init?) is the one host-mediated egress that works on every
execution path, gated by the network capability. Prefer it over the global
fetch: a record hook (and any warm runtime) runs in an isolate that denies raw
outbound by construction, so only ctx.net.fetch reaches the network there.
await ctx.net.fetch('https://hooks.example.com/notify', {
method: 'POST',
body: JSON.stringify({ text: 'deploy succeeded' }),
})
Daemons (experimental)
defineDaemon({ name?, run }) declares long-lived, per-repo:canonical-sha
compute woken by the host on a schedule.
import { defineDaemon } from '@thegarage/ext'
export default defineDaemon({
name: 'reminders',
async run(ctx) {
const open = await ctx.records.list({ where: { status: 'open' }, limit: 100 })
// sweep and notify…
},
})
Testing
Extension logic — KV scoping and gates, schema authority, the records engine,
and migrations — is covered by local unit tests (vp test --run) that run
against an in-process SQLite and mocked bindings, with no Cloudflare auth
required. To watch ctx.kv or a dispatch run end to end against real
bindings, use the worker dev session (vp run @thegarage/worker#dev), which
needs a CLOUDFLARE_API_TOKEN because the path to create a repo and load an
extension goes through object storage that has no local emulator.