.agents/skills/elysia/SKILL.md
Elysia.js server patterns for error handling, status responses, and plugin composition. Use when the user mentions Elysia, Eden Treaty, or when writing API route handlers, returning HTTP errors, creating server plugins, or working with type-safe API clients.
npx skillsauth add epicenterhq/epicenter elysiaInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this pattern when you need to:
status() responses.set.status plus error-object returns.return status(...) and throw status(...) by control-flow context.status() Helper (ALWAYS use this)Never use set.status + return object. Always destructure status from the handler context and use it for all non-200 responses. This gives you:
"Bad Request" instead of 400)import { Elysia, t } from 'elysia';
new Elysia().post(
'/chat',
async ({ body, headers, status }) => {
// ^^^^^^ destructure status from context
if (!isValid(body.provider)) {
// Use string literal for self-documenting, typesafe status codes
return status('Bad Request', 'Unsupported provider');
}
if (!apiKey) {
return status('Unauthorized', 'Missing API key');
}
return doWork(body);
},
{
// Define response schemas per status code for full type safety
response: {
200: t.Any(),
400: t.String(),
401: t.String(),
},
},
);
return status() vs throw status()Both work. The framework handles either. The difference is purely control flow:
| Pattern | Behavior | Use when |
| -------------------- | --------------------------------------------- | ---------------------------------------------------------------------- |
| return status(...) | Normal return, continues to response pipeline | You're at a natural return point (validation guards, end of handler) |
| throw status(...) | Short-circuits execution immediately | You're deep in nested logic or inside a try/catch and want to bail out |
This codebase convention: prefer return status(...). It matches the existing early-return-on-error pattern used everywhere else (see error-handling skill). Reserve throw status(...) for catch blocks or deeply nested code where return would be awkward.
// GOOD: return for validation guards (matches codebase style)
async ({ body, status }) => {
if (!isValid(body.provider)) {
return status('Bad Request', `Unsupported provider: ${body.provider}`);
}
const apiKey = resolveApiKey(body.provider, headerApiKey);
if (!apiKey) {
return status('Unauthorized', 'Missing API key');
}
// happy path
return doWork(body);
};
// GOOD: throw inside catch blocks
async ({ body, status }) => {
try {
return await streamResponse(body);
} catch (error) {
if (isAbortError(error)) {
throw status(499, 'Client closed request');
}
throw status('Bad Gateway', `Provider error: ${error.message}`);
}
};
Both return status(...) and throw status(...) produce the same ElysiaCustomStatusResponse object. Elysia's type system infers response types from the response schema in route options, not from how you invoke status(). Eden Treaty type safety works equally with either approach.
Use these string literals instead of numeric codes for better readability:
| String Literal | Code | Common Use |
| ------------------------- | ---- | ------------------------------------------ |
| 'Bad Request' | 400 | Validation failures, malformed input |
| 'Unauthorized' | 401 | Missing/invalid auth credentials |
| 'Forbidden' | 403 | Valid auth but insufficient permissions |
| 'Not Found' | 404 | Resource doesn't exist |
| 'Conflict' | 409 | State conflict (duplicate, already exists) |
| 'Unprocessable Content' | 422 | Semantically invalid input |
| 'Too Many Requests' | 429 | Rate limiting |
| 'Internal Server Error' | 500 | Unexpected server failure |
| 'Bad Gateway' | 502 | Upstream provider error |
| 'Service Unavailable' | 503 | Temporary overload/maintenance |
For non-standard codes (e.g. nginx's 499), use the numeric literal directly: status(499, 'Client closed request').
Define response schemas per status code in route options. This is what makes Eden Treaty infer error types on the client:
new Elysia().post(
'/chat',
async ({ body, status }) => {
if (!isValid(body.provider)) {
return status('Bad Request', `Unsupported provider: ${body.provider}`);
}
return streamResult;
},
{
body: t.Object({
provider: t.String(),
model: t.String(),
}),
response: {
200: t.Any(), // Success type
400: t.String(), // Bad Request body type
401: t.String(), // Unauthorized body type
502: t.String(), // Bad Gateway body type
},
},
);
Eden Treaty then infers:
const { data, error } = await api.chat.post({
provider: 'openai',
model: 'gpt-4',
});
if (error) {
// error.status is typed as 400 | 401 | 502
// error.value is typed per status code (string in this case)
switch (error.status) {
case 400: // error.value: string
case 401: // error.value: string
case 502: // error.value: string
}
}
Prefer plain strings as error bodies. The status code already communicates the error class. A descriptive string message is sufficient and keeps the API simple.
// GOOD: Plain string - status code provides the category
return status('Bad Request', `Unsupported provider: ${provider}`);
return status('Unauthorized', 'Missing API key: set x-provider-api-key header');
// AVOID: Wrapping in { error: "..." } object - redundant with status code
set.status = 400;
return { error: `Unsupported provider: ${provider}` };
If you need structured error bodies (multiple fields, error codes, validation details), define a TypeBox schema:
const ErrorBody = t.Object({
message: t.String(),
code: t.Optional(t.String()),
});
// In route options:
response: {
400: ErrorBody,
401: ErrorBody,
}
Elysia plugins are just functions that return Elysia instances. Use new Elysia() inside the plugin, not new Elysia({ prefix }) — let the consumer control mounting:
// GOOD: Plugin is prefix-agnostic
export function createMyPlugin() {
return new Elysia().post('/endpoint', async ({ body, status }) => {
// ...
});
}
// Consumer controls the prefix
app.use(new Elysia({ prefix: '/api' }).use(createMyPlugin()));
Use .guard() with beforeHandle for auth that applies to multiple routes:
const authed = new Elysia().guard({
async beforeHandle({ headers, status }) {
const token = extractBearerToken(headers.authorization);
if (!isValid(token)) {
return status('Unauthorized', 'Invalid or missing token');
}
},
});
// All routes under this guard require auth
return authed
.get('/protected', () => 'secret')
.post('/admin', () => 'admin stuff');
set.status to status()When updating existing handlers:
set with status in the handler destructuringset.status = N; return { error: msg }; with return status('String Literal', msg);throw status(...) instead of set.status = N; return { error: msg };response schemas to route options for Eden Treaty type inferenceset in the destructuring ONLY if you still need set.headers for things like content-type// BEFORE
async ({ body, headers, set }) => {
if (!valid) {
set.status = 400;
return { error: 'Bad input' };
}
};
// AFTER
async ({ body, headers, status }) => {
if (!valid) {
return status('Bad Request', 'Bad input');
}
};
// AFTER (when you also need set.headers)
async ({ body, headers, set, status }) => {
if (!valid) {
return status('Bad Request', 'Bad input');
}
set.headers['content-type'] = 'application/octet-stream';
return binaryData;
};
documentation
Yjs CRDT patterns, shared types (Y.Map, Y.Array, Y.Text), conflict resolution, and document storage. Use when the user mentions Yjs, Y.Doc, CRDTs, collaborative editing, or when handling shared types, implementing real-time sync, or optimizing document storage.
tools
Voice and tone rules for all written content—prose, UI text, tooltips, error messages. Use when the user says "fix the tone", "rewrite this", "sounds like AI", "sounds corporate", or when writing any user-facing text, landing pages, product copy, or open-source documentation.
tools
Workspace API patterns for defineTable, defineKv, versioning, migrations, data access (CRUD + observation), withActions, and extension ordering. Use when the user mentions workspace, defineTable, defineKv, createWorkspace, withActions, withExtension, defineQuery, defineMutation, connectWorkspace, or when defining schemas, reading/writing table data, observing changes, writing migrations, chaining extensions, or attaching actions to a workspace client.
documentation
Standard workflow for implementing features with specs and planning documents. Use when the user says "start a new feature", "how should I plan this", "what's the process", or when starting implementation, planning work, or working on any non-trivial task.