Skip to content
pin

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: true with Resolver: true marks the canonical one-record fetch for a URIType. It answers ant get. In pin these are get, board show, and user show.
  • List: true marks a member-lister for a parent resource. It answers ant 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.