All skills
lukemurraynz avatar

/typespec-api-design

@2cc2455

Design new API-first, contract-first contracts with TypeSpec, OpenAPI/swagger output, Azure data-plane and ARM patterns, versioning, pagination, LROs, CI validation, and agent-ready API boundaries. WHEN: create API spec, API-first design, contract-first, scaffold TypeSpec project, generate OpenAPI or swagger, define REST contract, add API versioning, define LRO, migrate OpenAPI to TypeSpec, Azure API review, SpecKit API.

Use this Skill: https://skilld.dev/gh/lukemurraynz/hve-agent-skills/typespec-api-design

This session only. Nothing lands on disk.

bundlesgeneric-restguide.md

≈4.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Generic REST API bundle

Inherits hard rules from SKILL.md §Hard rules.

Use this bundle for non-Azure REST APIs that should emit OpenAPI 3.x and remain friendly to APIM, SDK generators, MCP tools, and agent/plugin consumers.

Recommended dependencies

{
  "type": "module",
  "private": true,
  "scripts": {
    "build": "tsp compile . --warn-as-error",
    "watch": "tsp compile . --watch",
    "format": "tsp format \"**/*.tsp\""
  },
  "dependencies": {
    "@typespec/compiler": "<verified-compatible-version>",
    "@typespec/http": "<verified-compatible-version>",
    "@typespec/rest": "<verified-compatible-version>",
    "@typespec/openapi3": "<verified-compatible-version>"
  },
  "devDependencies": {}
}

Before committing, replace <verified-compatible-version> with versions verified from official TypeSpec release notes, the repository lockfile, or tsp init output. Keep TypeSpec packages on compatible release families.

Recommended tspconfig.yaml

warn-as-error: true
emit:
  - "@typespec/openapi3"
options:
  "@typespec/openapi3":
    openapi-versions:
      - "3.0.0"

Use OpenAPI 3.1 only when every downstream tool is verified to support it.

Design rules

  • Path segments use plural nouns and kebab-case: /line-items, not /lineItems.
  • Property names use JSON-friendly camelCase.
  • Operation names use verb-noun semantics: listWidgets, createWidget, cancelOrder.
  • Avoid deep nesting beyond two resource levels unless the parent identity is required.
  • Prefer opaque public identifiers over database IDs.
  • Model errors explicitly with @error and status codes.
  • Include examples in examples/ and keep them aligned with emitted schemas.

Discriminated unions for polymorphism

TypeSpec unions emit as oneOf without discriminator by default. Agents and SDK generators cannot reliably deserialize discriminator-free oneOf. Use @discriminated to fix:

@discriminated(#{ envelope: "none", discriminatorPropertyName: "kind" })
union Event {
  Click: ClickEvent,
  View: ViewEvent,
}
model ClickEvent { kind: "Click"; element: string; }
model ViewEvent { kind: "View"; duration: int32; }

Note: @discriminated (for unions) is a core TypeSpec standard-library decorator (the TypeSpec namespace from @typespec/compiler) - it is available without importing @typespec/http. The alternative for inheritance hierarchies is @discriminator("kind") on a base model (also core). For Azure data-plane work, follow the @azure-tools/typespec-azure-core discriminator guidance (extensible-union discriminator property).

Query parameters in routes

Do NOT embed query parameters in @route(...) strings - TypeSpec emits the path-query diagnostic.

  • Wrong: @route("/widgets?type=active")
  • Right: @route("/widgets") op list(@query type: string)

For action verbs, prefer POST to a sub-route like /widgets/{id}:archive.

Response patterns

For single resources:

/** A single widget response. */
model WidgetResponse {
  /** The returned widget. */
  widget: Widget;
}

For lists, decide whether the API needs pagination now. For public APIs, prefer a paged envelope even if the first implementation is small.

/** A page of widgets. */
model WidgetListResponse {
  /** Current page of results. */
  items: Widget[];

  /** Token used to request the next page. */
  nextLink?: url;
}

Error pattern

/** Standard API error. */
@error
model ErrorResponse {
  @statusCode statusCode: 400 | 401 | 403 | 404 | 409 | 422 | 429 | 500;

  /** Stable machine-readable error code. */
  code: string;

  /** Human-readable error message safe to return to callers. */
  message: string;

  /** Optional correlation identifier for support. */
  correlationId?: string;
}

Security additions

  • Per-operation auth override. Override service-level auth for unauthenticated endpoints (health, ping, public discovery) using NoAuth from @typespec/http:

    using Http;
    
    @useAuth(NoAuth)
    op health(): { status: "ok" };
  • Rate-limit headers. Surface throttling on 429 Too Many Requests (and optionally 200 OK) responses using the IETF draft-ietf-httpapi-ratelimit-headers family plus standard Retry-After (the latter is standardized by RFC 9110). Two forms of the rate-limit headers exist:

(a) Legacy triple - RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset. Present in older drafts and widely deployed.

(b) Modern structured-field single header - RateLimit: "default";r=50;t=30 plus a separate RateLimit-Policy header (current draft).

Pick one form per API and document it. Example using the legacy triple:

@error
model TooManyRequests {
  @statusCode statusCode: 429;
  @header("Retry-After") retryAfter: int32;
  @header("RateLimit-Limit") rateLimitLimit: int32;
  @header("RateLimit-Remaining") rateLimitRemaining: int32;
  @header("RateLimit-Reset") rateLimitReset: int32;
  code: "TooManyRequests";
  message: string;
}
  • Error information disclosure. Error response bodies must NOT include stack traces, internal hostnames, SQL fragments, file paths, or raw exception messages. The contract should expose only code (stable, machine-readable), message (sanitized, human-readable), target (optional field path), and details[] for sub-errors. Put internal correlation identifiers in headers (x-ms-correlation-id, traceparent), never in the response body.

  • Idempotency key pattern. For POST operations that create resources or trigger non-idempotent side effects, accept an Idempotency-Key header. Hard rule #24 extends this to PATCH: the same key semantics apply, but scope the replay key to key + method + path + If-Match + body hash - two PATCHes sharing a key but carrying different If-Match values are distinct conditional updates, not replays:

    op createWidget(
      @header("Idempotency-Key")
      @maxLength(64)
      @pattern("^[A-Za-z0-9_-]+$")
      idempotencyKey?: string,
      @body widget: Widget,
    ): WidgetResponse | ErrorResponse;
  • Idempotency replay window. Server semantics for replay are a 24-hour window matching Idempotency-Key + route + body hash. These semantics live in draft-ietf-httpapi-idempotency-key-header; the TypeSpec contract only commits to accepting the header - runtime enforcement (storage, hashing, replay-window expiry, conflict detection) is the server's responsibility.

  • ReDoS warning. When using @pattern(...), avoid nested quantifiers ((a+)+, (.*)*), unbounded alternation overlap, and patterns prone to catastrophic backtracking. Always anchor with ^ and $. Test patterns against pathological inputs (long strings of the trigger character) before shipping. A regex that compiles is not necessarily safe.

    Worked examples:

    • Bad: ^(a+)+$ - catastrophic backtracking on input like aaaaaaaaaaX. Safe rewrite: ^a+$.
    • Bad: ^(\w+,)*\w+$ - overlapping repetition explodes on long unmatched inputs. Safe rewrite: ^\w+(,\w+)*$.
  • PII / data classification. Mark sensitive properties with a @doc classification note so downstream tooling, logs, and reviewers can identify them. Use values such as "PII - email", "PII - phone", "Secret - do not log", or "Confidential - financial". If your org defines a custom @dataClassification decorator, prefer it; otherwise rely on @doc plus a separate data-inventory document.

    model UserProfile {
      /** PII - email. Do not log in plain text. */
      email: string;
    
      /** Secret - do not log. */
      apiToken: string;
    }

Mass-assignment defense

Every server-assigned field MUST carry @visibility(Lifecycle.Read) so it is stripped from Create and Update request payloads. The emitter generates separate request and response schemas based on these visibility lifecycle hints, which prevents clients from overposting fields like id, createdAt, tenantId, or status flags.

model Widget {
  @visibility(Lifecycle.Read) id: string;
  @visibility(Lifecycle.Read) createdAt: utcDateTime;

  /** Caller-supplied display name. */
  name: string;
}

Audit every model used as a request body: any field the server populates (identity, timestamps, audit metadata, computed status) needs Lifecycle.Read. The emitter will then omit it from request schemas.

URL inputs and SSRF

If an API accepts URLs from clients (webhook callbacks, image-fetch URLs, file URLs, scraping targets), the url scalar provides ONLY syntax validation - it does NOT prevent Server-Side Request Forgery (SSRF). Treat URL parameters as untrusted and document the runtime contract in @doc:

/**
 * Webhook callback URL. Server enforces an allow-list of hosts and
 * refuses private IP ranges (RFC 1918, link-local, loopback,
 * cloud-metadata endpoints).
 */
callbackUrl: url;

The TypeSpec contract just commits the API to the policy - the gateway or service must enforce it at runtime (DNS resolution check, IP allow-list, host allow-list, redirect chain limits).

Cache-Control for sensitive responses

Responses containing PII, auth-scoped data, or per-user records should declare Cache-Control: no-store to prevent intermediary caches (CDNs, proxies, browser disk caches) from retaining bytes that may leak across users.

op getUserProfile(@path id: string): {
  @statusCode statusCode: 200;
  @header("Cache-Control") cacheControl: "no-store";
  @body profile: UserProfile;
} | ErrorResponse;

For public, non-personalized resources, use a normal Cache-Control directive (public, max-age=...) instead - no-store is the explicit signal that the response must not be persisted.

Sunset / Deprecation signaling

When deprecating an operation or model, combine the #deprecated TypeSpec directive with HTTP runtime signaling. The directive surfaces deprecation in tooling, docs, and IDE hovers; the headers signal it to live clients.

  • #deprecated "reason" - TypeSpec directive on the operation or model. Place above the declaration.
  • Deprecation response header (RFC 9745, March 2025, which superseded draft-ietf-httpapi-deprecation-header) on every response from a deprecated endpoint. Per RFC 9745 the value is a Structured-Field Date in the form @<unix-timestamp> (e.g. Deprecation: @1688169599) giving the moment deprecation took or takes effect - the older "true"/HTTP-date forms from the draft are no longer the standard.
  • Sunset response header (RFC 8594) carrying the planned retirement date as an HTTP-date.
  • Link response header with rel="successor-version" pointing at the replacement and/or rel="deprecation" pointing at migration documentation.
#deprecated "Use Widgets_listV2; retiring 2027-01-01. See https://docs.example.com/migrate/v2"
op listWidgets(): {
  @statusCode statusCode: 200;
  @header("Deprecation") deprecation: "@1798761600"; // RFC 9745 structured-field date (= 2027-01-01T00:00:00Z)
  @header("Sunset") sunset: "Fri, 01 Jan 2027 00:00:00 GMT";
  @header("Link") link: "<https://docs.example.com/migrate/v2>; rel=\"successor-version\", <https://docs.example.com/deprecation>; rel=\"deprecation\"";
  @body widgets: WidgetListResponse;
};

For property-level deprecation, use @removed(Versions.vX) in the versioning bundle (see bundles/lro-pagination-versioning/guide.md) - it's the only safe way to remove a property without breaking older clients. #deprecated on a property keeps it in the schema with a warning; @removed retires it cleanly in a specific version.

Operational checklist for shipping a deprecation:

  1. Add #deprecated directive and the three response headers in the same PR.
  2. Publish the migration guide referenced by the Link header before the deprecation ships.
  3. Set Sunset at least 6 months out for partner APIs, 12 months for public APIs.
  4. Track deprecated-endpoint traffic - do not retire while non-trivial calls remain.

Correlation / trace context

Every production API needs a request-correlation header so support, logs, and distributed traces can be linked across the call graph. Model it in the contract, do not leave it implicit.

Standards. W3C Trace Context defines traceparent (required, well-formed trace ID + parent span ID) and tracestate (optional, vendor extensions). RFC 9457-style Problem Details and most observability stacks (OpenTelemetry, Application Insights, AWS X-Ray, Honeycomb) recognize these headers.

import "@typespec/http";
using Http;

/**
 * W3C Trace Context request/response headers (https://www.w3.org/TR/trace-context/).
 * `traceparent` carries the trace ID; `tracestate` carries vendor extensions.
 * Both flow in on the request and SHOULD be echoed on the response so clients
 * can correlate without parsing the body.
 */
model TraceContextHeaders {
  @header("traceparent")
  @pattern("^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$")
  traceparent?: string;

  @header("tracestate")
  tracestate?: string;
}

/**
 * Optional client-supplied request ID for callers that do not implement
 * W3C Trace Context. The server echoes it on the response and includes
 * it in error-correlation logs.
 */
model ClientRequestIdHeader {
  @header("x-ms-client-request-id")
  @maxLength(64)
  @pattern("^[A-Za-z0-9_-]+$")
  clientRequestId?: string;
}

Spread these into operations or interfaces that need them:

op getWidget(
  @path id: string,
  ...TraceContextHeaders,
  ...ClientRequestIdHeader,
): WidgetResponse | ErrorResponse;

Server semantics (enforced at runtime, not in the contract):

  • If traceparent is well-formed, propagate it into downstream calls. If malformed or absent, generate a new one and use it.
  • Echo traceparent and x-ms-client-request-id on every response, including error responses.
  • Include both in structured log entries so a single support ticket can be traced end-to-end.
  • Do NOT put correlation IDs in URL paths or query parameters - they end up in proxy/CDN logs and link previews.

File upload patterns

Two patterns work for client → API file transfer. Pick one per resource type and document it.

Pattern A - Pre-signed URL (preferred for files > a few MB). Client requests a short-lived signed URL from your API, then PUTs the file directly to blob storage. The API tier never streams large bodies.

/** Request for an upload URL. */
model UploadUrlRequest {
  @doc("Logical filename. Used for content-disposition only.")
  @maxLength(255)
  filename: string;

  @doc("MIME type of the upload. Validated server-side against an allow-list.")
  @maxLength(127)
  contentType: string;

  @doc("Expected size in bytes. Capped server-side (see service limits).")
  @minValue(1)
  sizeBytes: int64;
}

/** Short-lived signed-URL grant. */
model UploadUrlGrant {
  @doc("Opaque blob identifier to reference in subsequent API calls.")
  blobId: string;

  @doc("Pre-signed URL the client PUTs the file body to. Single-use.")
  uploadUrl: url;

  @doc("URL expiry as an absolute UTC timestamp. Typically 15 minutes.")
  expiresAt: utcDateTime;

  @doc("HTTP method the client must use against uploadUrl. Usually PUT.")
  method: "PUT" | "POST";

  @doc("Headers the client MUST include verbatim when uploading.")
  requiredHeaders: Record<string>;
}

@route("/uploads")
interface Uploads {
  /** Request a pre-signed upload URL. */
  @post create(@body request: UploadUrlRequest): UploadUrlGrant | ErrorResponse;
}

Runtime checks (enforced by your service, not the TypeSpec contract): allow-list of contentType, cap on sizeBytes, scoped storage credential (single blob, single method, short expiry), virus scan on the resulting blob before any read operation succeeds.

Pattern B - multipart/form-data (for small files or where the upload must be tied to a request body). Use @multipartBody:

import "@typespec/http";
using Http;

model AvatarUpload {
  @doc("Image bytes. PNG or JPEG, max 2 MB enforced at runtime.")
  file: HttpPart<File>;

  @doc("Optional alt text.")
  altText?: HttpPart<string>;
}

@route("/users/{userId}/avatar")
@put
op uploadAvatar(
  @path userId: string,
  @multipartBody body: AvatarUpload,
): { @statusCode statusCode: 204 } | ErrorResponse;

The File type lives in Http. The emitter writes multipart/form-data and named parts into the OpenAPI document.

When to pick which:

  • Pre-signed URL → media (images, video, documents), backups, anything > ~5 MB, anything where the API tier shouldn't proxy bytes.
  • Multipart → form-style submissions tying a file to other fields in a single transaction, or files small enough that a 30-second timeout is comfortable.

Never accept a client-supplied URL and have the server fetch it ("send me the URL, I'll download"). That's a textbook SSRF pattern. If you must support it, see bundles/webhooks/guide.md for the egress-allowlist controls that apply.

Agent-consumable API checks

Before routing to App-as-Skill, MCP, or Microsoft 365 Copilot work:

  • Every operation has a clear description and semantic name.
  • Parameters are few, documented, constrained, and not overloaded.
  • Request and response bodies are named models, not anonymous inline shapes.
  • Error responses are predictable.
  • Auth scheme is explicit.
  • Destructive operations are clearly named and documented.

Source: SKILL.md on GitHub

No alerts8d3 checks · Risk SAFE
  • Gen Agent Trust Hub8d

    This skill is a comprehensive and legitimate tool for designing API-first contracts using TypeSpec and Azure patterns. It includes extensive security best practices, particularly regarding SSRF defense, PII handling, and idempotency. The only detected concern is a standard vulnerability surface for indirect prompt injection when processing external OpenAPI specifications for migration.

  • Socket8d

    No alerts

  • Snyk8d

    Risk: LOW · No issues

Signed by skilld at 2cc2455. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated last month
metadata
{
  "last_verified": "2026-08-26",
  "version": "1.3.1"
}

README badge

README badge for lukemurraynz/hve-agent-skills/typespec-api-design