SDK design patterns — API ergonomics, backward compatibility (semantic versioning, deprecation), multi-language SDK generation (openapi-generator vs Speakeasy), error hierarchy design, SDK testing strategies, and documentation as first-class SDK artifact.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
The simplest use case must be accomplishable in 3 lines. Full control must still be possible.
# Level 1 — 3 lines for the happy path
from acmecorp import Client
client = Client(api_key="sk-...")
result = client.users.create(email="user@example.com")
# Level 2 — override defaults when needed
result = client.users.create(
email="user@example.com",
role="admin",
send_welcome_email=False,
)
# Level 3 — full control (raw HTTP, custom retry, custom headers)
result = client.users.create(
email="user@example.com",
request_options=RequestOptions(
timeout_ms=5000,
max_retries=1,
additional_headers={"X-Idempotency-Key": "idem-123"},
),
)
The default path must be the safe, correct path. Safe defaults are on; users opt out only when they have a reason.
// GOOD: retries=3, timeout=30s, SSL on by default — users only set what differs
const client = new AcmeClient({ apiKey: process.env.ACME_API_KEY });
Users discover SDK capabilities through autocomplete. Design types and method signatures accordingly.
// BAD: stringly-typed — no autocomplete, easy to mistype
client.events.create("user.signup", { userId: "usr_123" });
// GOOD: typed enum — autocomplete shows valid events, typos caught at compile time
client.events.create(EventType.UserSignup, { userId: "usr_123" });
// GOOD: discriminated union — IDE shows exactly what each event type requires
type Event =
| { type: "user.signup"; userId: string; email: string }
| { type: "order.completed"; orderId: string; amount: number };
Same concept, same name, same behavior — everywhere in the SDK.
# GOOD: consistent resource.verb pattern + unified pagination
client.users.list(limit=20) # → Page(items=[...], has_more=bool, next_cursor=str|None)
client.orders.list(limit=20) # same shape — not client.get_orders() or client.fetch_orders()
Fewer, more powerful primitives beat many specialized methods.
# BAD: method explosion
client.users.create_admin()
client.users.create_viewer()
client.users.create_with_custom_role()
client.users.create_and_send_invite()
# GOOD: single method with options
client.users.create(email=..., role="admin", send_invite=True)
Use keyword args for complex operations (self-documenting). Use Builder in Java; object options in TypeScript/Python.
# GOOD: keyword args — self-documenting
client.send_email(to="user@example.com", subject="Welcome!", track_opens=True, retry_count=3)
# GOOD: sane defaults — users override only what they need
class AcmeClient:
def __init__(self, api_key: str, base_url="https://api.acmecorp.com",
timeout=30.0, max_retries=3, http_client=None): ...
// Java — Builder for complex configuration
AcmeClient client = AcmeClient.builder()
.apiKey(System.getenv("ACME_API_KEY"))
.timeout(Duration.ofSeconds(30))
.retryPolicy(RetryPolicy.exponentialBackoff(maxAttempts=3))
.build();
users = (
client.users
.where(status="active").where(role="admin")
.order_by("created_at", direction="desc")
.limit(50).execute()
)
| Change | Version Bump | Examples |
|---|---|---|
| Remove a method or field | MAJOR | client.users.get() → removed |
| Rename a method or field | MAJOR | create_user() → create() |
| Change a required param to required | MAJOR | New required field |
| Add a new method | MINOR | client.users.bulk_create() |
| Add an optional param | MINOR | New optional kwarg with default |
| Bug fix | PATCH | Wrong status code returned |
| Add a new optional field to response | MINOR | New JSON field (clients ignore unknown) |
import warnings
import functools
def deprecated(replacement: str, removed_in: str):
"""Decorator to mark a function as deprecated with migration guidance."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(
f"{func.__name__} is deprecated and will be removed in v{removed_in}. "
f"Use {replacement} instead. "
f"Migration guide: https://docs.acmecorp.com/migration/v{removed_in}",
DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return wrapper
return decorator
class UsersResource:
@deprecated(replacement="users.list()", removed_in="3.0")
def get_all_users(self, **kwargs):
"""Deprecated: use users.list() instead."""
return self.list(**kwargs)
def list(self, limit=20, cursor=None):
"""Current method."""
...
/** @deprecated Use `users.list()` instead. Will be removed in v3.0.
* @see https://docs.acmecorp.com/migration/v3
*/
async getAllUsers(options?: ListOptions): Promise<Page<User>> {
console.warn("getAllUsers() is deprecated. Use users.list() instead.");
return this.list(options);
}
# TypeScript: api-extractor detects breaking API surface changes
npx @microsoft/api-extractor run --local
# Go: gorelease detects breaking changes vs. previous tag
go run golang.org/x/exp/cmd/gorelease@latest
# Java: revapi
mvn revapi:check
# Automate in CI — fail PR if breaking change is undocumented
# .github/workflows/api-compat.yml:
# - run: npx @microsoft/api-extractor run
# if: breaking changes found → require MAJOR version bump or explicit override
| Tool | Best for | DX quality |
|---|---|---|
openapi-generator-cli | Open-source, broad language support | Functional but raw |
| Speakeasy / Stainless | Production-quality SDKs with better ergonomics | High |
# openapi-generator — TypeScript, Python, Go from one spec
npx openapi-generator-cli generate -i openapi/v1/openapi.yaml \
-g typescript-axios -o sdks/typescript \
--additional-properties=npmName=@acmecorp/sdk,npmVersion=2.1.0,supportsES6=true
# Speakeasy — generate all languages at once
speakeasy generate sdk --schema openapi/v1/openapi.yaml --lang typescript,python,go
# .speakeasy/gen.yaml
generation:
baseServerUrl: https://api.acmecorp.com
sdkClassName: AcmeCorpSDK
languages:
typescript: { version: 2.1.0, packageName: "@acmecorp/sdk" }
python: { version: 2.1.0, packageName: acmecorp }
go: { version: 2.1.0, packageName: acmecorp }
sdk-monorepo/
├── openapi/v1/openapi.yaml # Single source of truth
├── sdks/
│ ├── typescript/ # @acmecorp/sdk (npm)
│ ├── python/ # acmecorp (PyPI)
│ └── go/ # github.com/acmecorp/sdk-go
└── .github/workflows/
├── generate-sdks.yml # On spec change: regenerate + PR
└── publish-sdks.yml # On tag: publish to registries
Map HTTP status codes to specific error types. Users catch exact errors; IDEs show typed properties.
# Python hierarchy: AcmeCorpError → APIError → specific subclasses
class AcmeCorpError(Exception):
def __init__(self, message: str, request_id: str | None = None):
super().__init__(message); self.request_id = request_id
class APIError(AcmeCorpError):
def __init__(self, message: str, status_code: int, **kwargs):
super().__init__(message, **kwargs); self.status_code = status_code
class AuthenticationError(APIError): # 401
def __init__(self, **kwargs): super().__init__("Invalid API key.", status_code=401, **kwargs)
class PermissionDeniedError(APIError): pass # 403
class NotFoundError(APIError): pass # 404
class ValidationError(APIError): # 422 — carries field errors list
def __init__(self, message, errors, **kwargs):
super().__init__(message, status_code=422, **kwargs); self.errors = errors
class RateLimitError(APIError): # 429 — carries retry_after seconds
def __init__(self, message, retry_after, **kwargs):
super().__init__(message, status_code=429, **kwargs); self.retry_after = retry_after
class InternalServerError(APIError): pass # 5xx — safe to retry
// TypeScript — same hierarchy, fully typed
export class AcmeCorpError extends Error {
constructor(message: string, public readonly requestId?: string) {
super(message); this.name = this.constructor.name;
}
}
export class RateLimitError extends AcmeCorpError {
constructor(message: string, public readonly retryAfter: number, requestId?: string) {
super(message, requestId);
}
}
// Usage — catch specific types, access typed properties
try {
await client.users.create({ email });
} catch (e) {
if (e instanceof RateLimitError) await sleep(e.retryAfter * 1000);
else if (e instanceof ValidationError) console.error(e.errors);
else throw e;
}
def _parse_error(response: httpx.Response) -> APIError:
try:
body = response.json()
message = body.get("detail", body.get("title", "Unknown error"))
request_id = response.headers.get("X-Request-ID")
except Exception:
message = response.text or f"HTTP {response.status_code}"; request_id = None
kw = {"request_id": request_id}
match response.status_code:
case 401: return AuthenticationError(**kw)
case 422: return ValidationError(message, errors=body.get("errors", []), **kw)
case 429: return RateLimitError(message,
retry_after=int(response.headers.get("Retry-After", 60)), **kw)
case s if s >= 500: return InternalServerError(message, status_code=s, **kw)
case _: return APIError(message, status_code=response.status_code, **kw)
Record real HTTP interactions once, replay in tests — no mock drift, no network dependency.
@pytest.mark.vcr # records on first run, replays thereafter
def test_create_user(client):
user = client.users.create(email="test@example.com")
assert user.id.startswith("usr_")
// TypeScript — nock replays recorded fixtures
nock("https://api.acmecorp.com").post("/v1/users")
.reply(201, loadFixture("create_user_success.json"));
test("creates a user", async () => {
const user = await client.users.create({ email: "test@example.com" });
expect(user.id).toMatch(/^usr_/);
});
# Test across all supported runtime versions
strategy:
matrix:
include:
- { lang: typescript, node: "20" }
- { lang: python, python: "3.11" }
- { lang: go, go: "1.22" }
After publishing, smoke-test the actual artifact (not just source):
# TypeScript: pack + install in a fresh project
npm pack && npm install /path/to/acmecorp-sdk-2.1.0.tgz
node -e "const { AcmeCorpSDK } = require('@acmecorp/sdk'); console.log('OK')"
# Python: build wheel + install in fresh venv
python -m build && pip install dist/*.whl
python -c "import acmecorp; print('OK', acmecorp.__version__)"
from schemathesis import from_path
schema = from_path("openapi/v1/openapi.yaml", base_url="https://api-staging.acmecorp.com")
@schema.parametrize()
def test_api_contract(case):
response = case.call_and_validate() # validates request AND response against spec
assert response.status_code != 500
def create(self, email: str, name: str | None = None, role: str = "member") -> User:
"""
Create a new user.
Args:
email: The user's email address. Must be unique within your organization.
name: The user's display name. Defaults to the part before @ in the email.
role: Access level. One of: "admin", "member", "viewer". Defaults to "member".
Returns:
User: The newly created user object.
Raises:
ValidationError: If email format is invalid or email already exists.
AuthenticationError: If the API key is invalid.
RateLimitError: If the rate limit is exceeded (see `error.retry_after`).
Example:
>>> user = client.users.create(email="alice@example.com", role="admin")
>>> print(user.id)
usr_01HX4KQVNR3ZGK4FMJQB7T8C2
"""
# Changelog
## [2.1.0] - 2026-03-08
### Added
- `client.users.bulk_create()` for creating up to 100 users in one API call
- `RateLimitError.retry_after` — seconds until retry is safe
### Deprecated
- `client.users.get_all_users()` — use `client.users.list()` instead. Removed in v3.0.
# .github/workflows/publish-sdks.yml
on:
push:
tags:
- "v*" # trigger on any version tag
jobs:
publish-typescript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", registry-url: "https://registry.npmjs.org" }
- run: cd sdks/typescript && npm ci && npm run build && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
publish-python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: cd sdks/python && pip install build twine && python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
api-contract — OpenAPI spec authoring before SDK generationapi-design — REST API design principles (resource naming, pagination, errors)problem-details — RFC 7807 error format for SDK error parsingcontract-testing — Pact and OpenAPI contract tests for SDK validationrelease-management — semantic versioning and changelog automation