Skip to content

Start typing to search the documentation.

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-checking

Author 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 build

Promote 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 main

garage 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). paths globs 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/output Zod schemas are required for a dispatchable extension; input is validated before run.

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 opaque cursor (null when exhausted); a prefix is 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 kind is one of string, integer, boolean, json; string fields may declare an enum.
  • list({ where }) only accepts indexed fields — an unindexed filter throws. It returns a page of rows plus an opaque cursor.
  • 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.hooks flags 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, so ctx.garage.writeFile targets that branch.
  • Re-entrancy is guarded. A record mutation made inside a hook does not re-fire hooks (a record.hook.suppressed receipt 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.

Next up