Loading
Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
Recommended by author
## Context
Services use the init/accessor pattern: initialized once in `createApp`'s `setup()` callback, then accessed at request time via a lazy getter. Each service lives in `src/services/[domain]/` with an init function and accessor.
Service methods receive `Context` for correlated logging (`ctx.log`) and tenant-scoped storage (`ctx.state`). Convention: `ctx.elicit` should only be called from tool handlers, not from services.
For the full service pattern, `CoreServices`, and `Context` interface, read the framework's `CLAUDE.md`/`AGENTS.md` (loaded at session start).
## Steps
1. **Gather** the service domain name and what it integrates with from the user's request — ask only if genuinely absent
2. **Create the directory** at `src/services/[domain]/`
3. **Create the service file** at `src/services/[domain]/[domain]-service.ts`
4. **Create types** at `src/services/[domain]/types.ts` if needed
5. **Register in `setup()`** in the server's entry point (`src/index.ts`, or `src/worker.ts` for Worker-only servers)
6. **Run `bun run devcheck`** to verify
## Template
### Service file
```typescript
/**
* @fileoverview [SERVICE_DESCRIPTION]
* @module services/[domain]/[domain]-service
*/
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
import type { Context } from '@cyanheads/mcp-ts-core';
export class [ServiceName] {
constructor(
private readonly config: AppConfig,
private readonly storage: StorageService,
) {}
async doWork(input: string, ctx: Context): Promise<string> {
ctx.log.debug('Processing', { input });
// Domain logic here
return `result: ${input}`;
}
}
// --- Init/accessor pattern ---
let _service: [ServiceName] | undefined;
export function init[ServiceName](config: AppConfig, storage: StorageService): void {
_service = new [ServiceName](config, storage);
}
export function get[ServiceName](): [ServiceName] {
if (!_service) {
throw new Error('[ServiceName] not initialized — call init[ServiceName]() in setup()');
}
return _service;
}
```
### Entry point registration
Add the `setup()` callback and import to the existing `createApp()` call — preserve the existing tool/resource/prompt arrays:
```typescript
// In src/index.ts (or src/worker.ts for Worker-only servers)
import { init[ServiceName] } from './services/[domain]/[domain]-service.js';
// Add setup() alongside existing options:
setup(core) {
init[ServiceName](core.config, core.storage);
},
```
### Usage in tool handlers
```typescript
import { get[ServiceName] } from '@/services/[domain]/[domain]-service.js';
handler: async (input, ctx) => {
return get[ServiceName]().doWork(input.query, ctx);
},
```
## Resilience (External API Services)
When a service wraps an external API, apply these patterns. For the framework retry contract, see `skills/api-utils/SKILL.md`.
### Retry wraps the full pipeline
Place retry at the service method level — covering both HTTP fetch and response parsing/validation. The HTTP client should be single-attempt; the service owns retry. Use `withRetry` from `@cyanheads/mcp-ts-core/utils`:
```typescript
import { withRetry, fetchWithTimeout } from '@cyanheads/mcp-ts-core/utils';
import type { Context } from '@cyanheads/mcp-ts-core';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetchWithTimeout(
`${this.baseUrl}/items/${id}`,
10_000,
ctx,
{ signal: ctx.signal },
);
const text = await response.text();
return this.parseResponse<Item>(text);
},
{
operation: 'fetchItem',
context: ctx,
baseDelayMs: 1000, // calibrate to upstream recovery time
signal: ctx.signal,
},
);
}
```
### Key principles
1. **Calibrate backoff to the upstream.** 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default `baseDelayMs: 1000` suits most APIs.
2. **Check HTTP status before parsing.** `fetchWithTimeout` already throws on non-OK responses with granular status mapping (401→`Unauthorized`, 403→`Forbidden`, 404→`NotFound`, 408/425→`Timeout`, 422→`ValidationError`, 429→`RateLimited`, 5xx→`ServiceUnavailable`/`InternalError`) — this prevents feeding HTML error pages into XML/JSON parsers.
3. **Classify parse failures by content.** If the upstream returns HTTP 200 with an HTML error page, detect it and throw `ServiceUnavailable` (transient) instead of `SerializationError` (non-transient). **Exception — deterministic HTTP 200 errors fail fast, not transient.** Some upstreams return HTTP 200 with a structured error body for failures that will *never* succeed regardless of how many times you retry: a query too expensive for the server's budget, an oversized result set, or a malformed request the server rejects. Retrying these wastes upstream capacity and delays the client. Declare them in the contract with `retryable: false` (or pass `{ retryable: false }` in `data` at the throw site) — `withRetry`'s default predicate reads `error.data.retryable === false` and fails immediately, even for `Timeout`/`ServiceUnavailable` codes. `ctx.fail` auto-populates `data.retryable` from the contract entry, so declaring it once in `errors[]` is enough.
4. **Exhausted retries say so.** `withRetry` automatically enriches the final error with attempt count — callers know retries were already attempted.
### When you need finer-grained HTTP error classification
`fetchWithTimeout` already maps status codes to appropriate error codes (see key principle 2 above). Use `httpErrorFromResponse` instead when you need `Retry-After` header capture, request body passthrough in error data, or custom `service`/`data` fields on the thrown error:
```typescript
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetch(`${this.baseUrl}/items/${id}`, { signal: ctx.signal });
if (!response.ok) {
throw await httpErrorFromResponse(response, {
service: 'MyAPI',
data: { itemId: id },
});
}
return this.parseResponse<Item>(await response.text());
},
{ operation: 'fetchItem', context: ctx, signal: ctx.signal },
);
}
```
`httpErrorFromResponse` maps the full status table (401/403/408/422/429/5xx) to the appropriate `JsonRpcErrorCode`, captures the response body (truncated), and forwards `Retry-After` headers into `error.data.retryAfter`. The codes it produces line up with `withRetry`'s transient-code set, so retryable HTTP failures (429, 503, 504) are retried automatically and non-retryable ones (401, 404, 422) fail immediately.
### Response handler pattern
```typescript
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
parseResponse<T>(text: string): T {
// Detect HTML error pages masquerading as successful responses
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
throw serviceUnavailable('API returned HTML instead of expected format — likely rate-limited.');
}
// Parse and validate...
}
```
### Sparse upstream payloads
Third-party APIs often omit fields entirely instead of returning `null`. If your raw response types, normalized domain types, or tool output schemas are stricter than the real upstream payloads, you'll either fail validation or silently invent facts.
**Guidance:**
1. **Raw upstream types default to optional unless presence is guaranteed.** Trust the docs only after you've verified real payloads.
2. **Preserve absence when it means "unknown".** Missing data is different from `false`, `0`, `''`, or an empty array.
3. **Don't fabricate defaults during normalization** unless the upstream contract or your own tool semantics explicitly define them.
4. **With `exactOptionalPropertyTypes`, omit absent fields instead of returning `undefined`.** Conditional spreads keep the normalized object honest.
```typescript
type RawRepo = {
id: string;
name: string;
archived?: boolean;
star_count?: number;
description?: string | null;
};
type Repo = {
id: string;
name: string;
archived?: boolean;
starCount?: number;
description?: string;
};
function normalizeRepo(raw: RawRepo): Repo {
const description = raw.description?.trim();
return {
id: raw.id,
name: raw.name,
...(typeof raw.archived === 'boolean' && { archived: raw.archived }),
...(typeof raw.star_count === 'number' && { starCount: raw.star_count }),
...(description ? { description } : {}),
};
}
```
## Error Handling in Services
Services don't declare `errors: [...]` contracts and don't have `ctx.fail` — that contract surface is tool/resource-only. Inside services:
- **Throw via factories** when a specific code matters: `throw notFound(...)`, `throw rateLimited(...)`, `throw serviceUnavailable(...)`. The framework's auto-classifier catches anything else.
- **Wrap risky pipelines in `ErrorHandler.tryCatch`** when you want structured logging + auto-classification without writing try/catch boilerplate. It always rethrows — never swallows. Useful for parsing untrusted input (JSON, config) or third-party SDK calls whose error types you don't control:
```ts
import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
const parsed = await ErrorHandler.tryCatch(
() => JSON.parse(rawConfig),
{ operation: 'MyService.parseConfig', errorCode: JsonRpcErrorCode.ConfigurationError },
);
```
- **Tool/resource handlers bubble service errors unchanged** — the contract advertises the *advertised* failure surface, and any code thrown from a service still reaches the client correctly via the auto-classifier. The conformance lint scans handler source text only, so service-thrown codes aren't flagged.
- **Carry contract `reason` via `data: { reason }`** when the calling tool declares an `errors[]` contract entry for this failure mode. Services can't call `ctx.fail`, but passing the reason in `data` flows through the auto-classifier untouched, so clients see the same `error.data.reason` they'd see from `ctx.fail` — no handler-side catch-and-rethrow needed:
```ts
// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' }]
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
```
- **Resolve contract `recovery` via `ctx.recoveryFor`** to land the contract's recovery hint on the wire without duplicating the string. Always-present on `Context`, returns `{}` when the calling tool has no matching reason — spread-safe regardless:
```ts
throw validationError('Parse failed: ' + err.message, {
reason: 'parse_failed',
...ctx.recoveryFor('parse_failed'), // resolves from caller's contract
});
```
The contract `recovery` (validated ≥5 words at lint time) is the single source of truth. Services that opt in via the resolver carry the same hint to the wire that handler-level `ctx.fail` callers do — no drift, no auto-population. For dynamic recovery (interpolating runtime values into the hint), pass an explicit `{ recovery: { hint: '…' } }` instead.
## API Efficiency
When a service wraps an external API, design methods to minimize upstream calls. These patterns compound — a tool calling 3 service methods that each make N requests is 3N calls; batching drops it to 3.
### Batch over N+1
If the API supports filter-by-IDs, bulk GET, or batch query endpoints, expose a batch method instead of (or alongside) the single-item method. One request for 20 items beats 20 sequential requests — it eliminates serial latency, avoids rate-limit accumulation, and simplifies error handling.
```typescript
/** Fetch multiple studies in a single request via filter.ids. */
async getStudiesBatch(nctIds: string[], ctx: Context): Promise<Study[]> {
const response = await this.searchStudies({
filterIds: nctIds,
fields: ['NCTId', 'BriefTitle', 'HasResults', 'ResultsSection'],
pageSize: nctIds.length,
}, ctx);
return response.studies;
}
```
Cross-reference the response against the requested IDs to detect missing items — don't assume the API returns everything you asked for.
### Field selection
If the API supports `fields`, `select`, or `include` parameters, request only what the caller needs. A full record might be 70KB; four fields might be 5KB. Expose field selection as a parameter on the service method, or use sensible defaults per method.
### Pagination awareness
If a batch request might exceed the API's page size limit, either:
- Paginate internally (loop until all pages consumed), or
- Assert/throw when the response indicates truncation (e.g., `nextPageToken` present)
Silent truncation is a data integrity bug — the caller thinks it has all results when it doesn't.
## Checklist
- [ ] Directory created at `src/services/[domain]/`
- [ ] Service file created — `init` function accepts `(config: AppConfig, storage: StorageService)` and stores the instance
- [ ] Accessor function exported — throws `Error` if not initialized
- [ ] JSDoc `@fileoverview` and `@module` header present
- [ ] No `console` calls — use `ctx.log` for service-level logging
- [ ] Service methods accept `Context` for logging and storage
- [ ] `init` function registered in `setup()` callback in the server's entry point (`src/index.ts` or `src/worker.ts`)
- [ ] If wrapping external API: retry covers full pipeline (fetch + parse), backoff calibrated
- [ ] If wrapping external API: raw/domain types reflect real upstream sparsity; missing values are preserved as unknown, not fabricated into concrete facts
- [ ] If wrapping external API: batch endpoints used where available, field selection applied, pagination handled
- [ ] `bun run devcheck` passesRunning prompts needs a free account.
Sign in and we'll stream the response from Claude Opus 4.7 right here — no config needed for the platform models.
Scaffold a new service integration. Use when the user asks to add a service, integrate an external API, or create a reusable domain module with its own initialization and state.
## Context
Services use the init/accessor pattern: initialized once in `createApp`'s `setup()` callback, then accessed at request time via a lazy getter. Each service lives in `src/services/[domain]/` with an init function and accessor.
Service methods receive `Context` for correlated logging (`ctx.log`) and tenant-scoped storage (`ctx.state`). Convention: `ctx.elicit` should only be called from tool handlers, not from services.
For the full service pattern, `CoreServices`, and `Context` interface, read the framework's `CLAUDE.md`/`AGENTS.md` (loaded at session start).
## Steps
1. **Gather** the service domain name and what it integrates with from the user's request — ask only if genuinely absent
2. **Create the directory** at `src/services/{{domain}}/`
3. **Create the service file** at `src/services/{{domain}}/{{domain}}-service.ts`
4. **Create types** at `src/services/{{domain}}/types.ts` if needed
5. **Register in `setup()`** in the server's entry point (`src/index.ts`, or `src/worker.ts` for Worker-only servers)
6. **Run `bun run devcheck`** to verify
## Template
### Service file
```typescript
/**
* @fileoverview {{SERVICE_DESCRIPTION}}
* @module services/{{domain}}/{{domain}}-service
*/
import type { AppConfig } from '@cyanheads/mcp-ts-core/config';
import type { StorageService } from '@cyanheads/mcp-ts-core/storage';
import type { Context } from '@cyanheads/mcp-ts-core';
export class {{ServiceName}} {
constructor(
private readonly config: AppConfig,
private readonly storage: StorageService,
) {}
async doWork(input: string, ctx: Context): Promise<string> {
ctx.log.debug('Processing', { input });
// Domain logic here
return `result: ${input}`;
}
}
// --- Init/accessor pattern ---
let _service: {{ServiceName}} | undefined;
export function init{{ServiceName}}(config: AppConfig, storage: StorageService): void {
_service = new {{ServiceName}}(config, storage);
}
export function get{{ServiceName}}(): {{ServiceName}} {
if (!_service) {
throw new Error('{{ServiceName}} not initialized — call init{{ServiceName}}() in setup()');
}
return _service;
}
```
### Entry point registration
Add the `setup()` callback and import to the existing `createApp()` call — preserve the existing tool/resource/prompt arrays:
```typescript
// In src/index.ts (or src/worker.ts for Worker-only servers)
import { init{{ServiceName}} } from './services/{{domain}}/{{domain}}-service.js';
// Add setup() alongside existing options:
setup(core) {
init{{ServiceName}}(core.config, core.storage);
},
```
### Usage in tool handlers
```typescript
import { get{{ServiceName}} } from '@/services/{{domain}}/{{domain}}-service.js';
handler: async (input, ctx) => {
return get{{ServiceName}}().doWork(input.query, ctx);
},
```
## Resilience (External API Services)
When a service wraps an external API, apply these patterns. For the framework retry contract, see `skills/api-utils/SKILL.md`.
### Retry wraps the full pipeline
Place retry at the service method level — covering both HTTP fetch and response parsing/validation. The HTTP client should be single-attempt; the service owns retry. Use `withRetry` from `@cyanheads/mcp-ts-core/utils`:
```typescript
import { withRetry, fetchWithTimeout } from '@cyanheads/mcp-ts-core/utils';
import type { Context } from '@cyanheads/mcp-ts-core';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetchWithTimeout(
`${this.baseUrl}/items/${id}`,
10_000,
ctx,
{ signal: ctx.signal },
);
const text = await response.text();
return this.parseResponse<Item>(text);
},
{
operation: 'fetchItem',
context: ctx,
baseDelayMs: 1000, // calibrate to upstream recovery time
signal: ctx.signal,
},
);
}
```
### Key principles
1. **Calibrate backoff to the upstream.** 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default `baseDelayMs: 1000` suits most APIs.
2. **Check HTTP status before parsing.** `fetchWithTimeout` already throws on non-OK responses with granular status mapping (401→`Unauthorized`, 403→`Forbidden`, 404→`NotFound`, 408/425→`Timeout`, 422→`ValidationError`, 429→`RateLimited`, 5xx→`ServiceUnavailable`/`InternalError`) — this prevents feeding HTML error pages into XML/JSON parsers.
3. **Classify parse failures by content.** If the upstream returns HTTP 200 with an HTML error page, detect it and throw `ServiceUnavailable` (transient) instead of `SerializationError` (non-transient). **Exception — deterministic HTTP 200 errors fail fast, not transient.** Some upstreams return HTTP 200 with a structured error body for failures that will *never* succeed regardless of how many times you retry: a query too expensive for the server's budget, an oversized result set, or a malformed request the server rejects. Retrying these wastes upstream capacity and delays the client. Declare them in the contract with `retryable: false` (or pass `{ retryable: false }` in `data` at the throw site) — `withRetry`'s default predicate reads `error.data.retryable === false` and fails immediately, even for `Timeout`/`ServiceUnavailable` codes. `ctx.fail` auto-populates `data.retryable` from the contract entry, so declaring it once in `errors[]` is enough.
4. **Exhausted retries say so.** `withRetry` automatically enriches the final error with attempt count — callers know retries were already attempted.
### When you need finer-grained HTTP error classification
`fetchWithTimeout` already maps status codes to appropriate error codes (see key principle 2 above). Use `httpErrorFromResponse` instead when you need `Retry-After` header capture, request body passthrough in error data, or custom `service`/`data` fields on the thrown error:
```typescript
import { httpErrorFromResponse, withRetry } from '@cyanheads/mcp-ts-core/utils';
async fetchItem(id: string, ctx: Context): Promise<Item> {
return withRetry(
async () => {
const response = await fetch(`${this.baseUrl}/items/${id}`, { signal: ctx.signal });
if (!response.ok) {
throw await httpErrorFromResponse(response, {
service: 'MyAPI',
data: { itemId: id },
});
}
return this.parseResponse<Item>(await response.text());
},
{ operation: 'fetchItem', context: ctx, signal: ctx.signal },
);
}
```
`httpErrorFromResponse` maps the full status table (401/403/408/422/429/5xx) to the appropriate `JsonRpcErrorCode`, captures the response body (truncated), and forwards `Retry-After` headers into `error.data.retryAfter`. The codes it produces line up with `withRetry`'s transient-code set, so retryable HTTP failures (429, 503, 504) are retried automatically and non-retryable ones (401, 404, 422) fail immediately.
### Response handler pattern
```typescript
import { serviceUnavailable } from '@cyanheads/mcp-ts-core/errors';
parseResponse<T>(text: string): T {
// Detect HTML error pages masquerading as successful responses
if (/^\s*<(!DOCTYPE\s+html|html[\s>])/i.test(text)) {
throw serviceUnavailable('API returned HTML instead of expected format — likely rate-limited.');
}
// Parse and validate...
}
```
### Sparse upstream payloads
Third-party APIs often omit fields entirely instead of returning `null`. If your raw response types, normalized domain types, or tool output schemas are stricter than the real upstream payloads, you'll either fail validation or silently invent facts.
**Guidance:**
1. **Raw upstream types default to optional unless presence is guaranteed.** Trust the docs only after you've verified real payloads.
2. **Preserve absence when it means "unknown".** Missing data is different from `false`, `0`, `''`, or an empty array.
3. **Don't fabricate defaults during normalization** unless the upstream contract or your own tool semantics explicitly define them.
4. **With `exactOptionalPropertyTypes`, omit absent fields instead of returning `undefined`.** Conditional spreads keep the normalized object honest.
```typescript
type RawRepo = {
id: string;
name: string;
archived?: boolean;
star_count?: number;
description?: string | null;
};
type Repo = {
id: string;
name: string;
archived?: boolean;
starCount?: number;
description?: string;
};
function normalizeRepo(raw: RawRepo): Repo {
const description = raw.description?.trim();
return {
id: raw.id,
name: raw.name,
...(typeof raw.archived === 'boolean' && { archived: raw.archived }),
...(typeof raw.star_count === 'number' && { starCount: raw.star_count }),
...(description ? { description } : {}),
};
}
```
## Error Handling in Services
Services don't declare `errors: [...]` contracts and don't have `ctx.fail` — that contract surface is tool/resource-only. Inside services:
- **Throw via factories** when a specific code matters: `throw notFound(...)`, `throw rateLimited(...)`, `throw serviceUnavailable(...)`. The framework's auto-classifier catches anything else.
- **Wrap risky pipelines in `ErrorHandler.tryCatch`** when you want structured logging + auto-classification without writing try/catch boilerplate. It always rethrows — never swallows. Useful for parsing untrusted input (JSON, config) or third-party SDK calls whose error types you don't control:
```ts
import { ErrorHandler } from '@cyanheads/mcp-ts-core/utils';
const parsed = await ErrorHandler.tryCatch(
() => JSON.parse(rawConfig),
{ operation: 'MyService.parseConfig', errorCode: JsonRpcErrorCode.ConfigurationError },
);
```
- **Tool/resource handlers bubble service errors unchanged** — the contract advertises the *advertised* failure surface, and any code thrown from a service still reaches the client correctly via the auto-classifier. The conformance lint scans handler source text only, so service-thrown codes aren't flagged.
- **Carry contract `reason` via `data: { reason }`** when the calling tool declares an `errors[]` contract entry for this failure mode. Services can't call `ctx.fail`, but passing the reason in `data` flows through the auto-classifier untouched, so clients see the same `error.data.reason` they'd see from `ctx.fail` — no handler-side catch-and-rethrow needed:
```ts
// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' }]
throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });
```
- **Resolve contract `recovery` via `ctx.recoveryFor`** to land the contract's recovery hint on the wire without duplicating the string. Always-present on `Context`, returns `{}` when the calling tool has no matching reason — spread-safe regardless:
```ts
throw validationError('Parse failed: ' + err.message, {
reason: 'parse_failed',
...ctx.recoveryFor('parse_failed'), // resolves from caller's contract
});
```
The contract `recovery` (validated ≥5 words at lint time) is the single source of truth. Services that opt in via the resolver carry the same hint to the wire that handler-level `ctx.fail` callers do — no drift, no auto-population. For dynamic recovery (interpolating runtime values into the hint), pass an explicit `{ recovery: { hint: '…' } }` instead.
## API Efficiency
When a service wraps an external API, design methods to minimize upstream calls. These patterns compound — a tool calling 3 service methods that each make N requests is 3N calls; batching drops it to 3.
### Batch over N+1
If the API supports filter-by-IDs, bulk GET, or batch query endpoints, expose a batch method instead of (or alongside) the single-item method. One request for 20 items beats 20 sequential requests — it eliminates serial latency, avoids rate-limit accumulation, and simplifies error handling.
```typescript
/** Fetch multiple studies in a single request via filter.ids. */
async getStudiesBatch(nctIds: string[], ctx: Context): Promise<Study[]> {
const response = await this.searchStudies({
filterIds: nctIds,
fields: ['NCTId', 'BriefTitle', 'HasResults', 'ResultsSection'],
pageSize: nctIds.length,
}, ctx);
return response.studies;
}
```
Cross-reference the response against the requested IDs to detect missing items — don't assume the API returns everything you asked for.
### Field selection
If the API supports `fields`, `select`, or `include` parameters, request only what the caller needs. A full record might be 70KB; four fields might be 5KB. Expose field selection as a parameter on the service method, or use sensible defaults per method.
### Pagination awareness
If a batch request might exceed the API's page size limit, either:
- Paginate internally (loop until all pages consumed), or
- Assert/throw when the response indicates truncation (e.g., `nextPageToken` present)
Silent truncation is a data integrity bug — the caller thinks it has all results when it doesn't.
## Checklist
- [ ] Directory created at `src/services/{{domain}}/`
- [ ] Service file created — `init` function accepts `(config: AppConfig, storage: StorageService)` and stores the instance
- [ ] Accessor function exported — throws `Error` if not initialized
- [ ] JSDoc `@fileoverview` and `@module` header present
- [ ] No `console` calls — use `ctx.log` for service-level logging
- [ ] Service methods accept `Context` for logging and storage
- [ ] `init` function registered in `setup()` callback in the server's entry point (`src/index.ts` or `src/worker.ts`)
- [ ] If wrapping external API: retry covers full pipeline (fetch + parse), backoff calibrated
- [ ] If wrapping external API: raw/domain types reflect real upstream sparsity; missing values are preserved as unknown, not fabricated into concrete facts
- [ ] If wrapping external API: batch endpoints used where available, field selection applied, pagination handled
- [ ] `bun run devcheck` passes{{domain}}{{service_description}}{{servicename}}{{input}}{{id}}