Skip to content
garage
Esc
navigateopen⌘Jpreview
On this page

Extension reference

Source formats, triggers, capabilities, records, webhooks, and daemons.

An extension is a TypeScript module that you commit under .garage/. An extension can do these operations:

  • respond to a push or an event
  • expose a callable tool
  • send a webhook
  • store scoped data
  • run on a schedule

The authoring commands and APIs are experimental. Local authoring requires Node.js 24 or later.

garage ext new <name> writes .garage/extensions/<name>.ts. It also writes a package.json and a tsconfig.json. These two files give your editor the types from @thegarage/ext. Pass --force to overwrite an existing source file.

See Build and run your first extension for a worked example and Build and promote extensions for the activation procedure on its own.

Extension types

Source path Authoring function Use
.garage/extensions/<name>.ts defineExtension Push, event, or explicit dispatch runs
.garage/tools/<name>.ts defineTool Typed calls from the CLI, HTTP API, or MCP
.garage/webhooks/<name>.ts defineWebhook Signed outbound HTTP delivery
.garage/daemons/<name>.ts defineDaemon Scheduled or event-driven background work

All source types use @thegarage/ext for types and authoring helpers.

Extensions

defineExtension() accepts:

Field Required Description
description No One-line summary
input For dispatch Zod input schema
output For dispatch Zod output schema
dispatch No Expose the extension for explicit calls
on No Push and repository-event triggers
runtime No Execution runtime; defaults to worker
capabilities No Host APIs requested by the extension
record No Record schema owned by the extension
run(ctx, input) Yes Extension entry point
onRecordCreate No Record creation handler
onRecordTransition No Record transition handler
import { defineExtension, z } from '@thegarage/ext'

export default defineExtension({
  description: 'Return a greeting.',
  dispatch: true,
  capabilities: [],
  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.log(message)
    return { message }
  },
})

@thegarage/ext re-exports z.

Triggers

An extension can use a push trigger, a repository-event trigger, explicit dispatch, or a combination of these.

export default defineExtension({
  on: {
    push: { branches: ['main'] },
    event: { types: ['release.*'] },
  },
  dispatch: true,
  // input, output, capabilities, and run
})
Field Description
on.push.branches Short branch names or full refs
on.event.types Event-type globs
on.event.subject Exact subject filter
dispatch Exposes the extension through garage runs dispatch

Emit an event with:

await ctx.events.emit({
  type: 'release.published',
  subject: 'v1.2.0',
  payload: { commit: ctx.sha },
})

The extension must request the events capability.

Tools

defineTool() creates a directly callable extension. It requires an input schema and an output schema. It also enables dispatch automatically.

import { defineTool, z } from '@thegarage/ext'

export default defineTool({
  description: 'Read a committed file.',
  capabilities: ['repoRead'],
  input: z.object({ path: z.string().min(1) }),
  output: z.object({ content: z.string() }),
  async run(ctx, input) {
    return ctx.git.read({ path: input.path, ref: ctx.ref })
  },
})

Run promoted tools with:

garage tools list
garage tools run read-file --input '{"path":"README.md"}'

The repository MCP endpoint also publishes a promoted tool.

Capabilities and context

An extension declares each host API it uses.

Capability Context methods
none ctx.log
repoRead ctx.git.read, tree, blob, commits, commit, diff, compare, refs
repoWrite ctx.git.write
kvRead / kvWrite ctx.kv.get, list, put, delete
recordsRead / recordsWrite ctx.records.get, list, create, update, delete
events ctx.events.emit
network ctx.net.fetch

List capabilities in the extension source:

capabilities: ['repoRead', 'repoWrite']

garage isolates scoped KV and records by repository and by extension.

Write repository files

ctx.git.write() commits all files and removals in one operation:

const result = await ctx.git.write({
  files: [{ path: 'generated.txt', content: 'generated\n' }],
  message: 'Update generated files',
  ref: ctx.ref,
})

await ctx.log(`Created ${result.commit.oid}`)

At least one file or removal is required.

Dependencies

Declare runtime packages in .garage/package.json:

{
  "dependencies": {
    "semver": "^7.8.0"
  }
}

This file is not the package.json that garage ext new writes at the repository root. The root file carries @thegarage/ext as a devDependency for the editor types, and the build never reads it. .garage/package.json is the runtime manifest.

The manifest accepts a dependencies object and an optional private: true. The manifest does not support these items:

  • scripts
  • development dependencies
  • peer dependencies
  • aliases
  • dist-tags
  • Git, HTTP, or file specifiers

garage supplies @thegarage/ext and zod. Do not declare them in the manifest.

Each build resolves the package ranges again. A package can fail to build for one of these reasons:

  • it needs a native addon
  • it needs an install script
  • it needs a Node.js API that is not available
  • its dependency versions conflict

Records

An extension or tool can own one record type declared with defineRecord():

import { defineExtension, defineRecord, z } from '@thegarage/ext'

export default defineExtension({
  dispatch: true,
  capabilities: ['recordsWrite'],
  record: defineRecord({
    name: 'issue',
    fields: {
      title: { kind: 'string', required: true },
      status: {
        kind: 'string',
        enum: ['open', 'closed'],
        default: 'open',
      },
    },
    indexes: ['status'],
  }),
  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 })
    return { id: issue.id }
  },
})

A record field supports string, integer, number, boolean, and json. A filter in records.list({ where }) must use an indexed field. An update and a delete both require expectedRevision.

onRecordCreate and onRecordTransition respond to a record change. Promotion applies a supported additive change to the schema. Promotion rejects an unsafe or ambiguous change.

Webhooks

defineWebhook() declares an outbound webhook:

import { defineWebhook } from '@thegarage/ext'

export default defineWebhook({
  url: 'https://example.com/hooks/garage',
  on: { push: { branches: ['main'] } },
})

garage signs deliveries with X-Garage-Signature-256.

Command Description
garage webhooks secret <webhook> Retrieve the verification secret
garage webhooks deliveries List delivery attempts
garage webhooks redeliver <id> Retry a delivery
garage webhooks enable <webhook> Enable delivery
garage webhooks disable <webhook> Disable delivery

Daemons

defineDaemon() declares scheduled work:

import { defineDaemon } from '@thegarage/ext'

export default defineDaemon({
  schedule: { intervalMinutes: 5 },
  capabilities: ['kvWrite'],
  async run(ctx) {
    await ctx.kv.put('last-run', new Date().toISOString())
  },
})

The interval must be an integer of five minutes or more. An on.event filter and onEvent(ctx, event) can also wake a daemon for a matching repository event. garage runs one invocation of a daemon at a time. If a daemon misses one or more scheduled intervals, garage starts one run. It does not start one run for each missed interval.

Was this page helpful?