API Development
SkillDev toolsLets your agent add or modify API endpoints by writing TypeSpec definitions and updating the OpenAPI spec.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the API Development skill
About this capability
Add or modify API endpoints using TypeSpec. Use when adding new API routes, modifying request/response types, or changing the OpenAPI spec.
What this skill tells your AI
The instructions your AI receives, as published by openmeterio/openmeter in .agents/skills/api/SKILL.md and read by ahel’s review.
You are helping the user add or modify API endpoints in OpenMeter.
Context
- API spec source:
api/spec/packages/— TypeSpec definitions (two packages:aipfor v3,legacyfor v1) - Local server port: The server runs on port 8888 by default locally (
http://localhost:8888/api/v3) - New APIs go in AIP package:
api/spec/packages/aip/src/— all new endpoints must be added here - Generated outputs (DO NOT edit manually):
api/openapi.yaml,api/openapi.cloud.yaml— OpenAPI specsapi/client/javascript/,api/client/go/— SDK clientsapi/api.gen.go,api/v3/api.gen.go— Go server code (oapi-codegen)
AIP (v3) API Structure
api/spec/packages/aip/src/
├── main.tsp # Top-level imports
├── openmeter.tsp # Service definition, routes, and interface wiring
├── konnect.tsp # Konnect-specific service definition (must mirror openmeter.tsp's tags + route interfaces)
├── common/ # Shared types: errors, pagination, parameters
├── shared/ # Shared resources: ULID, request/response wrappers, tags
├── meters/ # Domain: models + operations
├── customers/ # Domain: models + operations
├── subscriptions/ # ...
├── billing/
├── apps/
├── currencies/
├── llmcost/
└── ...
Each domain typically has:
index.tsp— imports for the domain<resource>.tsp— model/type definitionsoperations.tsp— interface with CRUD operations
Routes are wired in api/spec/packages/aip/src/openmeter.tsp via interface declarations with @route and @tag decorators.
api/spec/packages/aip/src/konnect.tsp is the parallel Konnect-flavoured service definition. The two files are not identical — Konnect has its own service metadata, namespace name, @useAuth configuration, security scheme models, and intentionally exposes a narrower subset of the OpenMeter surface. But for any domain that is exposed in both, every new domain import, @tagMetadata(...) entry, and @route / @tag interface must be added to both files in the same edit. Diff the two files before generating to spot accidental drift; existing differences are expected, but a tag/route you just added showing up in only one file is a bug.
Workflow
Follow these steps in order:
Step 1: Edit the TypeSpec API spec
For a new domain/resource:
- Create a new directory under
api/spec/packages/aip/src/<domain>/ - Add
index.tsp, model file(s), andoperations.tsp - Import the domain in
api/spec/packages/aip/src/openmeter.tspandapi/spec/packages/aip/src/konnect.tsp(unless the domain is intentionally OpenMeter-only — confirm with the user before excluding it from Konnect) - Wire up the route interface (and any new
@tagMetadata) in bothopenmeter.tspandkonnect.tsp. After editing, rundiff openmeter.tsp konnect.tspand check that your new imports / tags / interfaces appear on both sides — pre-existing differences (service metadata, namespace name,@useAuth, security scheme models) are intentional and unrelated to your change.
For modifying an existing endpoint:
- Find the relevant files under
api/spec/packages/aip/src/<domain>/ - Edit the model or operations as needed
Look at existing domains (e.g., meters/, customers/) for conventions:
- Use
Shared.CreateRequest<T>,Shared.GetResponse<T>,Shared.PagePaginatedResponse<T>wrappers - For v3 cursor-based list endpoints, prefer
Shared.CursorPaginatedResponse<T>over endpoint-specific cursor meta models. In generated Go, this maps toapi.CursorMetaPage, wherenext/previousarenullable.Nullable[string]andsizeisfloat32; handlers may still return opaque cursor tokens and leavefirst/lastunset. - Use
Common.ErrorResponses,Common.NotFoundfor error types - Use
Common.PagePaginationQueryfor list operations - Use
@operationId,@summary,@tagdecorators on operations - Use
Shared.ULIDfor resource IDs in path parameters - Routes follow the pattern
/openmeter/<resource>
Step 2: Generate API code
Run:
make gen-api
This generates the OpenAPI spec, SDK clients, and Go server stubs. Check that it completes without errors.
Then run:
make generate
This regenerates Go server code from the updated OpenAPI spec (oapi-codegen).
Step 3: Implement the handler
After generating, implement the handler package and wire it into the server.
Handler Package Structure
Each handler domain lives at api/v3/handlers/<domain>/ and contains:
handler.go— Handler interface + constructor<operation>.go— One file per operation (create.go, list.go, get.go, delete.go)convert.go— Domain ↔ API type mapping functions
Reference: api/v3/handlers/llmcost/
Handler Interface & Constructor (handler.go)
package <domain>
type Handler interface {
List<Resource>s() List<Resource>sHandler
Create<Resource>() Create<Resource>Handler
Get<Resource>() Get<Resource>Handler
Delete<Resource>() Delete<Resource>Handler
}
type handler struct {
resolveNamespace func(ctx context.Context) (string, error)
service <domain>.Service
options []httptransport.HandlerOption
}
func New(
resolveNamespace func(ctx context.Context) (string, error),
service <domain>.Service,
options ...httptransport.HandlerOption,
) Handler {
return &handler{
resolveNamespace: resolveNamespace,
service: service,
options: options,
}
}
Reference: api/v3/handlers/llmcost/handler.go
Handler Operation Pattern (<operation>.go)
Each operation file uses httptransport.NewHandlerWithArgs with 4 arguments:
- Request decoder — parse HTTP request → domain input, resolve namespace
- Operation function — call service, map result to API response type
- Response encoder —
commonhttp.JSONResponseEncoderWithStatus[T](http.StatusXxx) - Options —
httptransport.AppendOptions(h.options, httptransport.WithOperationName("..."), httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()))
List endpoints with filtering: if the operation supports
?filter[...]query parameters, use the/api-filtersskill for the decoder and adapter wiring. It coversapi/v3/filters.Parse, the typed filter structs,Convert*helpers, range splitting, and the Ent.Select(field)application — everything this skill does not cover.
Type alias convention at top of file:
type (
List<Resource>sRequest = <domain>.List<Resource>sInput
List<Resource>sResponse = response.PagePaginationResponse[api.<Resource>]
List<Resource>sParams = api.List<Resource>sParams
List<Resource>sHandler = httptransport.HandlerWithArgs[List<Resource>sRequest, List<Resource>sResponse, List<Resource>sParams]
)
Full example:
func (h *handler) List<Resource>s() List<Resource>sHandler {
return httptransport.NewHandlerWithArgs(
// 1. Request decoder
func(ctx context.Context, r *http.Request, params List<Resource>sParams) (List<Resource>sRequest, error) {
ns, err := h.resolveNamespace(ctx)
if err != nil {
return List<Resource>sRequest{}, err
}
req := List<Resource>sRequest{
Namespace: ns,
}
// Pagination
req.Page = pagination.NewPage(1, 20)
if params.Page != nil {
req.Page = pagination.NewPage(
lo.FromPtrOr(params.Page.Number, 1),
lo.FromPtrOr(params.Page.Size, 20),
)
if err := req.Page.Validate(); err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "page", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
}
// Sort
if params.Sort != nil {
sort, err := request.ParseSortBy(*params.Sort)
if err != nil {
return req, apierrors.NewBadRequestError(ctx, err, apierrors.InvalidParameters{
{Field: "sort", Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery},
})
}
if !validSortField(sort.Field) {
return req, apierrors.NewBadRequestError(ctx, fmt.Errorf("unsupported sort field: %s", sort.Field), apierrors.InvalidParameters{
{Field: "sort", Reason: fmt.Sprintf("unsupported sort field %q", sort.Field), Source: apierrors.InvalidParamSourceQuery},
})
}
req.OrderBy = sort.Field
req.Order = sort.Order.ToSortxOrder()
}
return req, nil
},
// 2. Operation function
func(ctx context.Context, request List<Resource>sRequest) (List<Resource>sResponse, error) {
result, err := h.service.List<Resource>s(ctx, request)
if err != nil {
return List<Resource>sResponse{}, fmt.Errorf("failed to list: %w", err)
}
items := lo.Map(result.Items, func(item <domain>.<Resource>, _ int) api.<Resource> {
return domainToAPI(item)
})
return response.NewPagePaginationResponse(items, response.PageMetaPage{
Size: request.Page.PageSize,
Number: request.Page.PageNumber,
Total: lo.ToPtr(result.TotalCount),
}), nil
},
// 3. Response encoder
commonhttp.JSONResponseEncoderWithStatus[List<Resource>sResponse](http.StatusOK),
// 4. Options
httptransport.AppendOptions(
h.options,
httptransport.WithOperationName("list-<resource>s"),
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
)...,
)
}
For handlers without params (e.g., Create), use httptransport.NewHandler (3 arguments, no params):
type (
Create<Resource>Handler = httptransport.Handler[Create<Resource>Request, Create<Resource>Response]
)
Reference: api/v3/handlers/llmcost/list_prices.go
Converters (convert.go)
Converters translate API request bodies into service/domain types faithfully —
they must not enforce domain rules (valid type combinations, required-field
semantics, cross-field constraints). Map invalid combinations onto the domain
type as-is (zero values where needed) and let the domain Validate() report
the issue; that path produces user-serviceable errors with codes, field paths,
and severity, and keeps draft-with-issues semantics working (warning-severity
issues are surfaced on the draft and block publish instead of failing create).
A converter may only error when the body cannot be mapped at all — unknown
union discriminator, unparsable amount or duration. Wrap such errors in
models.NewGenericValidationError so they surface as 400, not 500.
Error Mapping
Domain errors auto-map to HTTP status codes via the error encoder:
GenericNotFoundError→ 404GenericValidationError→ 400GenericConflictError→ 409GenericForbiddenError→ 403GenericPreConditionFailedError→ 412
No need to manually handle these — just return them from the service and the error encoder handles it.
Structured Validation Errors (ValidationIssue)
In v3 API handlers, use models.ValidationIssue for structured validation errors with codes, field paths, and severity levels. This is the handler-layer pattern — service/adapter layers continue using models.NewGenericValidationError().
// Define validation issues as package-level variables
var errMissingName = models.NewValidationError("missing_name", "name is required")
var errInvalidCurrency = models.NewValidationWarning("invalid_currency", "currency not recognized")
// Use with field paths
err := errMissingName.WithPathString("body", "name")
// Convert from domain errors to structured issues
issues, err := models.AsValidationIssues(domainErr)
Key types from pkg/models/validationissue.go:
models.NewValidationError(code, message)— critical severitymodels.NewValidationWarning(code, message)— warning severitymodels.NewValidationIssue(code, message, opts...)— with options.WithPathString("body", "field")— attach JSONPath field location.WithComponent(component)— attach component namemodels.AsValidationIssues(err)— convert error tree to structured issues
Step 4: Wire Handler into Server
Three files to modify:
1. api/v3/server/server.go:
- Add service to
Configstruct - Add handler field to
Serverstruct - Instantiate handler in
NewServer()using<domain>handler.New(resolveNamespace, config.<Domain>Service, httptransport.WithErrorHandler(config.ErrorHandler))
2. api/v3/server/routes.go:
- Add route methods that delegate to handler:
// For operations WITH params (list, get by ID, delete by ID):
func (s *Server) List<Resource>s(w http.ResponseWriter, r *http.Request, params api.List<Resource>sParams) {
s.<domain>Handler.List<Resource>s().With(params).ServeHTTP(w, r)
}
func (s *Server) Get<Resource>(w http.ResponseWriter, r *http.Request, id api.ULID) {
s.<domain>Handler.Get<Resource>().With(id).ServeHTTP(w, r)
}
// For operations WITHOUT params (create):
func (s *Server) Create<Resource>(w http.ResponseWriter, r *http.Request) {
s.<domain>Handler.Create<Resource>().ServeHTTP(w, r)
}
3. Import the handler package in server.go.
Reference: api/v3/server/server.go:138-218, api/v3/server/routes.go
Step 5: Review
- Check the generated
api/openapi.yamlorapi/v3/api.gen.goto verify the endpoints look correct - Present a summary of the API changes to the user
AIP Standards (Kong AIP)
OpenMeter v3 APIs follow Kong's AIP conventions. Each rule lives in its own file under rules/ next to this SKILL — open the rule file you need for the task at hand.
Rule index
| File | Covers |
|---|---|
rules/aip-122-naming.md | Naming conventions + base resource models (Shared.Resource) |
rules/aip-126-enums.md | Enum wire values, unknown zero member, prefer-enum-over-bool |
rules/aip-visibility.md | @visibility + Lifecycle.Read/Create/Update |
rules/aip-134-135-crud.md | Create/Get/Update/Upsert/Delete templates, PATCH rules, DELETE rules |
rules/aip-132-list.md | List endpoints, sort, trailing slash |
rules/aip-158-pagination.md | Page-based and cursor-based pagination |
rules/aip-160-filtering.md | Filter query syntax, Common.*FieldFilter types, label dot-notation |
rules/aip-129-labels.md | Label key constraints, PATCH-with-null semantics |
rules/aip-193-errors.md | AIP-193 RFC-7807 error responses, invalid_parameters, 403-before-404 rule |
rules/openmeter-error-types.md | OpenMeter Common.* error types wiring AIP-193 onto operations |
rules/inline-errors.md | Inline (partial / non-fatal) errors via Shared.BaseError<T> for 2xx responses |
rules/aip-composition.md | Composition-over-inheritance (spread, model is, @discriminator) |
rules/aip-docs.md | @doc//** */ requirements, @operationId, @summary |
rules/aip-181-stability.md | x-private / x-unstable / x-internal stability markers |
rules/aip-142-time.md | RFC-3339 timestamps, ISO-8601 duration deviation |
rules/aip-137-content-type.md | Content-Type validation, 415 on unsupported |
rules/aip-235-bulk-delete.md | POST .../bulk-delete transactional vs 207 partial |
rules/aip-3101-versioning.md | URL-path versioning, per-resource versioning |
rules/aip-3106-empty-fields.md | Always return all fields, null / [] / {} for empty |
For filtering specifically, rules/aip-160-filtering.md covers the TypeSpec side (which Common.*FieldFilter to pick, Shared.ResourceFilters, label dot-notation, deepObject exposure). The Go implementation side — parsing deepObject query params into typed filters, converting to pkg/filter, and applying Ent predicates — is in the /api-filters skill.
Important Reminders
- All new APIs go in the AIP package (
api/spec/packages/aip/src/) - Legacy/v1 APIs are in
api/spec/packages/legacy/src/— avoid adding new endpoints there - Never edit generated files manually (
api/openapi.yaml,api/client/,api/*.gen.go) - Run
make gen-apito generate Go types - Follow existing TypeSpec patterns and conventions from other AIP domains
- When implementing the service layer, follow
docs/development/service-patterns.md
Signals
- GitHub stars
- 2k
- Forks
- 215
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
api-openmeterio- Source
- github.com/openmeterio/openmeter