Skip to content
garage
Esc
navigateopen⌘Jpreview
On this page

Build and run your first extension

Scaffold, build, promote, and run an extension, then grant a tool a capability.

In this guide, you add an extension to a repository and run it. You then add a tool that reads a committed file. The guide also shows why you must build and promote extension source, and why a push alone is not sufficient.

You need a repository that you can push to, and you must sign in to the CLI. If you do not have a repository, complete the Quickstart first and stay in the hello-world clone.

Part 1: an extension you can call

Scaffold the source

From the root of your clone:

garage ext new greet

This writes three files:

  • .garage/
    • extensions/
      • greet.ts
  • package.json
  • tsconfig.json

package.json and tsconfig.json let your editor type-check the extension against @thegarage/ext. Run npm install to get that now. This step is optional, because the server does the build.

Read the generated extension

Open .garage/extensions/greet.ts:

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

export default defineExtension({
  description: 'Say hello from the greet extension',
  input: z.object({
    name: z.string().default('garage'),
  }),
  output: z.object({
    message: z.string(),
  }),
  dispatch: true,
  on: {
    push: { branches: ['main'] },
  },
  runtime: 'worker',
  capabilities: [],
  async run(ctx, input) {
    const message = `hello ${input.name}`
    await ctx.log(message)
    return { message }
  },
})

Three fields control how this extension starts. dispatch: true lets you call it explicitly. on.push runs it each time main receives a push. capabilities: [] requests no host APIs, so the extension can only compute and log.

Commit and push the source

git add .garage package.json tsconfig.json
git commit -m "Add greet extension"
git push

This push matters. The build reads committed source from the server. The build never reads your working tree, so it cannot see an unpushed edit.

Build the pushed commit

garage ext build

The command compiles the source and writes a descriptor next to it at .garage/extensions/greet.descriptor.json. The descriptor records the extension’s public shape: its input and output schemas, its triggers, and the capabilities it requests.

Commit and push the descriptor

git add .garage
git commit -m "Build greet extension"
git push

The source and the descriptor now live in the same history. Each commit therefore describes what the extension does and which capabilities it requests.

Promote the configuration

garage ext promote

Promotion copies the current default-branch commit to refs/heads/garage. This dedicated ref holds the promoted configuration, which is the only configuration that garage runs.

Run it

garage runs dispatch greet --input '{"name":"world"}'

The command queues a run. It prints the run id and the exact command that follows the run. Use that id to read the result:

garage runs get <run-id>
garage runs logs <run-id>

The logs contain hello world, the line the extension passed to ctx.log.

The extension also declares on.push. A push to main therefore starts it automatically. garage starts only the promoted configuration in this way, so neither of the two pushes above started the extension.

Part 2: a tool that needs a capability

The greet extension requested no capabilities. Next, you add a tool that reads a file from the repository. That tool needs the repoRead capability.

Write the tool

Create .garage/tools/read-file.ts:

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 })
  },
})

defineTool creates a dispatchable extension with required input and output schemas. Tools appear in the tool catalog and on the repository’s MCP endpoint.

Build and push it

git add .garage
git commit -m "Add read-file tool"
git push
garage ext build
git add .garage
git commit -m "Build read-file tool"
git push

Check the capability before promotion

garage tools list
NAME       CANONICAL  CAPABILITIES  INPUT
greet      no         —  name?:string
read-file  no         —  path:string

The catalog lists every dispatchable extension, so greet appears here too. read-file is built and callable, but its CAPABILITIES column is empty. The last promotion came before this tool, so the tool holds no capabilities. The tool fails when it calls ctx.git.read. Try it:

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

This is the point of the two-step model. A push of the source cannot add a capability to an extension.

CANONICAL answers a different question. It shows whether the listed commit is the promoted configuration. garage tools list always reads the default branch, so this column stays no here even after you promote.

Promote and run

garage ext promote
garage tools list
NAME       CANONICAL  CAPABILITIES  INPUT
greet      no         —  name?:string
read-file  no         repoRead  path:string

read-file now holds repoRead. Now run it:

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

The tool returns the contents of README.md. garage tools run waits for the run to finish and then prints its output. garage runs dispatch does not wait.

What you learned

An extension moves through four states. Each state is a commit that you can review:

Each edit to an extension repeats the whole cycle. A change to the source needs a new build. A change to the requested capabilities takes effect only at the next promotion.

Was this page helpful?