Add a command
Model a Pinterest record and expose it as a command, a route, and a tool at once.
Each read command in pin is one operation, declared once, that becomes a CLI
subcommand, an HTTP route, an MCP tool, and a URI dereference. You add to the
tool in two files, and every surface updates itself.
1. Model the record
The records live in pinterest/types.go. Each exported struct carries the json
tags a reader sees and the kit tags that decide how a host addresses it. The
Pin record, for example:
type Pin struct {
ID string `json:"id" kit:"id"` // the URI id
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
URL string `json:"url"`
Image string `json:"image,omitempty"`
Pinner string `json:"pinner,omitempty"`
Board string `json:"board,omitempty"`
Saves int64 `json:"saves,omitempty"`
}
kit:"id"marks the field that becomes the URI id.table:",truncate"on a field keeps wide text from blowing up a terminal table.kit:"link,kind=<scheme>/<type>"on an edge field lets a host walk from one record to another, across tools when the link points at another site.
The client methods that fill these records live alongside the HTTP client; the
mapping from Pinterest's wire JSON to a record happens in parse.go.
2. Declare the operation
In pinterest/domain.go, add an input struct and a handler, then register it in
Register:
type pinRef struct {
Ref string `kit:"arg" help:"pin id or URL"`
Client *Client `kit:"inject"`
}
func getPin(ctx context.Context, in pinRef, emit func(*Pin) error) error {
p, err := in.Client.GetPin(ctx, in.Ref)
if err != nil {
return mapErr(err)
}
return emit(p)
}
// inside Register(app):
kit.Handle(app, kit.OpMeta{Name: "get", Group: "read", Single: true,
Summary: "Show one pin by id or URL", URIType: "pin", Resolver: true,
Args: []kit.Arg{{Name: "ref", Help: "pin id or URL"}}}, getPin)
That is the whole change. kit.Handle reflects the input for flags and the
output for the record shape, so the operation immediately becomes:
pin get <ref> # the command
curl 'localhost:7777/v1/get/<ref>' # the route, under serve
ant get pinterest://pin/<id> # the URI dereference, via a host
Resolver ops and list ops
Two flags shape how a host treats an operation:
Single: truewithResolver: truemarks the canonical one-record fetch for aURIType. It answersant get. Inpinthese areget,board show, anduser show.List: truemarks a member-lister for a parent resource. It answersant ls. A list op emits records that are themselves addressable, so every member is a URI a host can follow.board pins,user boards, and the topic and search feeds work this way.
Map errors to exit codes
Return the errs kinds from mapErr so every surface reports the same outcome
with the same exit code:
case errors.Is(err, ErrNotFound):
return errs.NotFound("%s", err.Error())
case errors.Is(err, ErrRateLimited):
return errs.RateLimited("%s", err.Error())
When a pin-grid feed comes back empty, the handler emits nothing and the surface exits with no results (exit 3) rather than fabricating.
See output formats for how records render, and resource URIs for how a host addresses them.