---
title: Extensions
description: 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.

**Note:**   Extensions are an evolving surface. The authoring API (`@thegarage/ext`) is stable and typed; some
  runtime paths are still landing — each is flagged inline below. Worked examples for every
  primitive live under `examples/extensions/` in the repo.

## The shape of an extension

An extension is a single default export built with `defineExtension`:

```ts
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.

```sh
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.

```sh
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.

```sh
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.

```ts
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.

**Note:**   Effective capabilities at run time are the **intersection** of what the branch requests and what
  the canonical config grants — a feature branch can never widen its own powers.

## 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.

```ts
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.

```ts
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**.

```ts
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`:

```jsonc
"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.

```ts
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.

**Note:**   Today only `onRecordCreate` fires (the records facet is create-only). `onRecordTransition` is
  wired end-to-end and waits on the record-update path.

## 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.

```ts
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.

```ts
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…
  },
})
```

**Caution:**   The **authoring surface ships now and type-checks**, but the runtime — alarm wakeups routed into a
  warm isolate, durable state, hot-swap on canonical advance — is not yet wired. `garage ext build`
  currently recognizes only a `defineExtension` default export. Treat `defineDaemon` as a preview of
  the intended shape.

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

  - [CLI reference](/docs/cli): The garage command groups, flags, and environment variables.
  - [For agents](/docs/agents): Wire garage into a coding agent and dispatch extensions as tools.
  - [Git SDK](/docs/sdk): Commit from any JavaScript runtime — no clone required.
