Design stable, hard-to-misuse interfaces — REST endpoints, MCP tool schemas, module boundaries, type contracts. Apply when defining new surfaces or evolving public ones. Triggers on "API design", "interface", "schema", "contract", "module boundary", "type design".
Scanned 5/28/2026
Install via CLI
openskills install nexus-substrate/nexus-agents---
name: api-and-interface-design
description: |
Design stable, hard-to-misuse interfaces — REST endpoints, MCP tool
schemas, module boundaries, type contracts. Apply when defining new
surfaces or evolving public ones. Triggers on "API design", "interface",
"schema", "contract", "module boundary", "type design".
allowed-tools: Read, Edit, Write, Bash, Grep, Glob
---
# API and Interface Design Skill
<!--
CANONICAL SOURCES:
- .rules/typescript.md (zero-any policy, strict types)
- .rules/untrusted-input.md (validate at boundaries, third-party = untrusted)
- docs/architecture/UNTRUSTED_INPUT_HARDENING.md
- CLAUDE.md "Type Safety — Zero `any` Policy"
- skills/deprecation-and-migration (when evolving existing public APIs)
- skills/references/security-checklist.md (input validation, OWASP Top 10)
Adapted from addyosmani/agent-skills (MIT, © 2025 Addy Osmani).
-->
## When to apply
- Designing a new MCP tool, CLI command, or HTTP endpoint
- Defining a public type contract between modules (especially across `src/` package boundaries)
- Designing component prop interfaces or hooks
- Establishing a database schema that informs API shape
- **Evolving an existing public interface** — see `deprecation-and-migration` for the safe-removal path
## Core principles
### Hyrum's Law
> With a sufficient number of users of an API, all observable behaviors of your system will be depended on by somebody, regardless of what you promise in the contract.
Every observable behavior — undocumented quirks, error message text, ordering, timing — becomes a de facto contract. Design implications:
- **Be intentional about what you expose.** Every public symbol is a commitment.
- **Don't leak implementation details.** If users can observe it, they will depend on it.
- **Plan for deprecation at design time.** New surfaces should be removable later — see `deprecation-and-migration`.
- **Tests aren't enough.** Even with perfect contract tests, "safe" changes can break consumers depending on undocumented behavior.
### One-Version Rule
Avoid forcing consumers to choose between multiple versions of the same dependency or API. Diamond-dependency problems arise when different consumers need different versions of the same thing. Design to extend rather than fork — see `CLAUDE.md` Anti-Sprawl Policy ("ONE canonical implementation path").
### Contract first
Define the interface before implementing it. The contract IS the spec — implementation follows.
```typescript
// Define the contract first
export interface ITaskAnalyzer {
analyze(task: Task): TaskAnalysisResult;
}
// Then implement
export class SharedTaskAnalyzer implements ITaskAnalyzer { ... }
```
### Consistent error semantics
Pick **one** error strategy and use it everywhere in a given surface:
- **Result-typed**: nexus-agents' canonical pattern. `Result<T, E>` returned, never thrown. See `core/result.ts`. Used by all MCP tool handlers, adapter calls, parsers.
- **Throwing**: only at process-entry boundaries (CLI top-level, MCP server invocation) or in synchronous validation that's expected to halt the request.
- **HTTP API**: status codes + a single error envelope `{ error: { code, message, details? } }`. Never mix shapes.
Do not mix patterns within one module. Consumers can't predict behavior when some endpoints throw, others return null, others return `{ error }`.
### Validate at boundaries
Trust internal code. Validate at system edges where external input enters. From `.rules/untrusted-input.md`:
| Boundary | Validate |
| --------------------------------- | ------------------------------------------------------------------------- |
| MCP tool input | Zod schema (we have one per tool) |
| HTTP route handlers | Zod schema before any business logic |
| External service responses | Zod schema — third-party = untrusted (see `UNTRUSTED_INPUT_HARDENING.md`) |
| Env var loading | Type-narrow + parse to typed config |
| File reads of user-supplied paths | Path-traversal guard (must be within cwd subtree) |
Internal modules accept already-validated types. Re-validating wastes work and obscures where validation actually lives.
> **Third-party API responses are untrusted data.** A compromised or misbehaving external service can return unexpected types, malicious content, or instruction-like text. Validate the shape AND content before using in any logic, rendering, or decision-making.
## Designing for consumers
### Discriminated unions over flag fields
```typescript
// Bad: consumer must check multiple fields and they can be inconsistent
interface TaskStatus {
status: string;
assignee?: string;
completedAt?: Date;
cancelReason?: string;
}
// Good: consumer gets type narrowing
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
```
### Input/Output separation
```typescript
interface CreateTaskInput {
title: string;
description?: string;
}
interface Task {
// includes server-generated fields
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
```
### Branded types for IDs
```typescript
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Prevents accidentally passing UserId where TaskId is expected
function getTask(id: TaskId): Promise<Task> { ... }
```
## Anti-rationalization — API design
| Excuse | Counter |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| "We'll document the API later" | Types ARE the documentation. Define them first; comments add color but the type is the contract. |
| "We don't need pagination for now" | You will the moment someone has > 100 items. List endpoints without pagination are a roadmap to performance bugs. |
| "We'll version the API when we need to" | Breaking changes without versioning break consumers silently. Design for additive extension from day one. |
| "Nobody uses that undocumented behavior" | Hyrum's Law: if it's observable, somebody depends on it. Treat every public symbol as a commitment. |
| "We can maintain two versions" | Multiple versions multiply maintenance cost and create diamond-dependency problems. Prefer One-Version + extend. |
| "Internal APIs don't need contracts" | Internal consumers are still consumers. Contracts prevent coupling and enable parallel work. |
| "Validation is duplicated, let me share it" | Validate at boundaries — once. Don't share validation between boundary and internal code; that's how internal trust gets broken. |
## Red flags
- Endpoints that return different shapes depending on conditions
- Inconsistent error formats across endpoints in one surface
- Validation scattered throughout internal code instead of at boundaries
- Breaking changes to existing fields (type changes, removals) without `deprecation-and-migration` discipline
- List endpoints without pagination
- Verbs in REST URLs (`/api/createTask` instead of `POST /api/tasks`)
- Third-party API responses used without Zod validation
- New `any` introduced (banned per CLAUDE.md zero-any policy)
- Public-barrel exports added without considering deprecation path
## Verification checklist
- [ ] Every public symbol has a typed input and output (no `any`, no untyped object passes)
- [ ] Error responses follow a single consistent shape across the surface
- [ ] Validation happens at system boundaries only — internal modules trust their typed inputs
- [ ] Third-party responses are Zod-validated before use
- [ ] List endpoints support pagination (or have a documented size bound)
- [ ] New fields are additive and optional (backward-compatible)
- [ ] Public exports go through `src/exports/*.ts` barrels (canonical surface), not direct module imports
- [ ] Naming follows existing conventions in the same area
- [ ] Type definitions are committed alongside the implementation (not deferred)
- [ ] Removal path is clear: how would this be deprecated and migrated later?
No comments yet. Be the first to comment!