add-service
Skill0 starsChanged 6 months agoApache-2.0View on GitHub
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.
3 other projects use a version of this.
Adopted by
Most recently taken up by
- lbedner/aegis-stacktheir own version, first seen Oct 8, 2026
- jblemee/docker-compose-homelabtheir own version, first seen Sep 3, 2026
- ludeo-labs/cosmos-ostheir own version, first seen Jul 27, 2026
Other versions3 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 3
- lbedner/aegis-stack★ 146same name
- jblemee/docker-compose-homelab★ 9same name
- ludeo-labs/cosmos-os★ 3same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository31 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- add-app-toolSkill
- add-promptSkill
- add-resourceSkill
- add-testSkill
- add-toolSkill
- api-authSkill
- api-canvasSkill
- api-configSkill
- api-contextSkill
- api-errorsSkill
- api-linterSkill
- api-mirrorSkill
- api-servicesSkill
- api-telemetrySkill
- api-testingSkill
- api-utilsSkill
- api-workersSkill
- code-simplifierSkill
- design-mcp-serverSkill
- field-testSkill
- git-wrapupSkill
- maintenanceSkill
- polish-docs-metaSkill
- release-and-publishSkill
- report-issue-frameworkSkill
- report-issue-localSkill
- security-passSkill
- setupSkill
- tool-defs-analysisSkill
The file
We split this skill into the rules it gives an agent
9 rules. 0 are common, 3 are distinctive, and 6 are very specific to this project.
0
Common
Most of the 3 edited versions we read keep these.
3
Distinctive
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 74
Add the setup() callback and import to the existing createApp() call — preserve the existing tool/resource/prompt arrays:
- Very specific: line 98
When a service wraps an external API, apply these patterns.
- Very specific: line 102
Place retry at the service method level — covering both HTTP fetch and response parsing/validation.
- Very specific: line 221
Throw via factories: when a specific code matters: throw notFound(...), throw rateLimited(...), throw serviceUnavailable(...).
- Very specific: line 222
Wrap risky pipelines in
ErrorHandler.tryCatch: when you want structured logging + auto-classification without writing try/catch … - Distinctive: line 254
When a service wraps an external API, design methods to minimize upstream calls.
- Distinctive: line 258
If the API supports filter-by-IDs, bulk GET, or batch query endpoints, expose a batch method instead of (or alongside) the single-item …
- Very specific: line 276
If the API supports fields, select, or include parameters, request only what the caller needs.
- Distinctive: line 280
If a batch request might exceed the API's page size limit, either:
“Common” is measured: kept by at least 6 in 10 of the 3 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
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 and ctx.sample 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
- Gather the service domain name and what it integrates with from the user's request — ask only if genuinely absent
- Create the directory at
src/services/{{domain}}/ - Create the service file at
src/services/{{domain}}/{{domain}}-service.ts - Create types at
src/services/{{domain}}/types.tsif needed - Register in
setup()in the server's entry point (src/index.ts, orsrc/worker.tsfor Worker-only servers) - Run
bun run devcheckto verify
Template
Service file
/**
* @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:
// 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
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:
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
- Calibrate backoff to the upstream. 200–500ms for ephemeral failures, 1–2s for rate-limited APIs, 2–5s for service degradation. The default
baseDelayMs: 1000suits most APIs. - Check HTTP status before parsing.
fetchWithTimeoutalready 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. - Classify parse failures by content. If the upstream returns HTTP 200 with an HTML error page, detect it and throw
ServiceUnavailable(transient) instead ofSerializationError(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 withretryable: false(or pass{ retryable: false }indataat the throw site) —withRetry's default predicate readserror.data.retryable === falseand fails immediately, even forTimeout/ServiceUnavailablecodes.ctx.failauto-populatesdata.retryablefrom the contract entry, so declaring it once inerrors[]is enough. - Exhausted retries say so.
withRetryautomatically 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:
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
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:
- Raw upstream types default to optional unless presence is guaranteed. Trust the docs only after you've verified real payloads.
- Preserve absence when it means "unknown". Missing data is different from
false,0,'', or an empty array. - Don't fabricate defaults during normalization unless the upstream contract or your own tool semantics explicitly define them.
- With
exactOptionalPropertyTypes, omit absent fields instead of returningundefined. Conditional spreads keep the normalized object honest.
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.tryCatchwhen 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: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
reasonviadata: { reason }when the calling tool declares anerrors[]contract entry for this failure mode. Services can't callctx.fail, but passing the reason indataflows through the auto-classifier untouched, so clients see the sameerror.data.reasonthey'd see fromctx.fail— no handler-side catch-and-rethrow needed:// tool declares: errors: [{ reason: 'empty_expression', code: JsonRpcErrorCode.ValidationError, when: '…', recovery: '…' }] throw validationError('Expression cannot be empty.', { reason: 'empty_expression' });Resolve contract
recoveryviactx.recoveryForto land the contract's recovery hint on the wire without duplicating the string. Always-present onContext, returns{}when the calling tool has no matching reason — spread-safe regardless: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-levelctx.failcallers 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.
/** 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.,
nextPageTokenpresent)
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 —
initfunction accepts(config: AppConfig, storage: StorageService)and stores the instance - Accessor function exported — throws
Errorif not initialized - JSDoc
@fileoverviewand@moduleheader present - No
consolecalls — usectx.logfor service-level logging - Service methods accept
Contextfor logging and storage initfunction registered insetup()callback in the server's entry point (src/index.tsorsrc/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 devcheckpasses
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

