Add a capability
Skill add-a-capability, from skills/add-a-capability/SKILL.md. An agent loads that file as a skill. This page shows the same text.
A capability is one named action: a contract plus one handler. Every use-case is one. Write the action once, and let each adapter reach it. The module does the work and checks the object. The capability only names the action and its gates.
This skill assumes the module exists. If it does not, follow skills/add-an-effect-module/SKILL.md first. Read packages/capability/README.md for the full API.
1. Name the permission in the module
Section titled “1. Name the permission in the module”The module that owns the data names its permissions, next to its data, as packages/bookings/src/permissions.ts does:
export const BookingPermissions = { read: "bookings:read",} as const;A permission is a resource:action string. Several capabilities can share one. Export it from the package’s src/index.ts.
Use "public" only for an action any caller may run, and only on purpose.
2. Define the contract
Section titled “2. Define the contract”Create apps/<app>/src/use-cases/<action>.ts. One file, one capability.
export const showBookingContract = defineContract("show_booking", { description: "Show one booking by its id.", input: Schema.Struct({ id: Schema.String }), output: Booking, failure: BookingNotFound, permission: BookingPermissions.read, annotations: { readOnly: true },});- Give it a stable name, such as
show_booking.toTooluses it as the MCP tool name. - Use the module’s schemas for
outputand itsSchema.TaggedErrorforfailure. - Set
readOnlyfor a read,destructivefor a delete or overwrite. Leave both out for a plain write. - Set
needsApproval: truewhen a human must confirm each call, such as delete or share. - An action with no input uses
NoInput, neverSchema.Struct({}).
3. Implement the handler
Section titled “3. Implement the handler”export const showBooking = implement(showBookingContract, ({ id }) => Effect.gen(function* showBookingHandler() { const bookings = yield* Bookings; return yield* bookings.get(id); }),);The handler yields services and calls their methods. Let typed errors flow. Do not catch them, do not open a transaction, and do not import another use-case. When the action acts for a user, yield the Viewer and pass its Actor to the service.
The file exports the contract, the capability, and types. Nothing else. use-case-is-capability fails anything more.
4. Call it from delivery
Section titled “4. Call it from delivery”An adapter in src/delivery/ calls showBooking.handler(input), provides the gate slots, and maps every typed error once:
- Provide
Grantfor the request, such asGrant.layerFromPermissions([...])from the caller’s role. - Provide
Approvalwhen the contract needs it:Approval.allowAllon the web, where the click is the yes;elicitationApprovalon MCP. - Map the contract’s failure, plus
ForbiddenandApprovalDeniedwhen the gates add them, withEffect.catchTags. The handler’s error channel ends asnever.
apps/api/src/delivery/http/get-booking-route.ts is the example. For an MCP tool, follow skills/add-an-mcp-tool/SKILL.md, which builds the tool with toTool(capability.contract, ...). Never call Tool.make by hand: no-hand-rolled-surface fails it.
5. Test it
Section titled “5. Test it”Put the test in apps/<app>/src/use-cases/tests/<action>.test.ts. Use @effect/vitest, with it.effect or it.layer. Never Effect.run*.
- The handler returns the output for a good input.
- It fails with the contract’s typed error. Inspect it with
Effect.flip. - With
Grant.denyAll, it fails withForbiddenbefore the service is called. - With
needsApproval,Approval.denyAllfails withApprovalDenied.
6. Prove it
Section titled “6. Prove it”pnpm checkpnpm testFix failures. Do not loosen a rule, a hook, or a pin.