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 greetThis writes three files:
- .garage/
- extensions/
- greet.ts
- extensions/
- 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 pushThis 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 buildThe 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 pushThe 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 promotePromotion 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 pushCheck the capability before promotion
garage tools listNAME CANONICAL CAPABILITIES INPUT
greet no — name?:string
read-file no — path:stringThe 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 listNAME CANONICAL CAPABILITIES INPUT
greet no — name?:string
read-file no repoRead path:stringread-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.
Extension model
Learn how the promoted configuration limits capabilities.
Extension reference
Look up triggers, capabilities, records, webhooks, and daemons.
Build and promote extensions
The build and promotion procedure on its own, including non-default refs.
Connect an MCP client
Expose the tool you just built to an MCP client.