Skills are how agents interact with the outside world. Every action an agent takes — sending an email, fetching a page, writing a spreadsheet entry — goes through a skill. Curia ships with a library of built-in skills, but you’ll likely want to add your own to connect internal systems, automate domain-specific workflows, or integrate services that Curia doesn’t support out of the box.
There are two types of skills: local skills (a directory with a manifest and a handler) and MCP skills (connections to external Model Context Protocol servers). Agents don’t know or care which type they’re using — the interface is identical from their perspective.
Local skills
Directory structure
Create a directory under skills/ with the skill’s name:
skills/
send-slack-message/
skill.json # manifest — declares inputs, outputs, sensitivity, secrets
handler.ts # TypeScript implementation
handler.test.ts # tests
The skill manifest (skill.json)
The manifest describes what the skill does, what it needs, and how risky it is. Curia validates it at startup — a missing or invalid field causes a startup error.
{
"name": "send-slack-message",
"description": "Post a message to a Slack channel via the Slack API",
"version": "1.0.0",
"sensitivity": "normal",
"action_risk": "medium",
"inputs": {
"channel": "string",
"text": "string",
"thread_ts": "string?"
},
"outputs": {
"ts": "string",
"channel": "string"
},
"permissions": [],
"secrets": ["slack_bot_token"],
"timeout": 15000
}
Manifest fields
| Field | Required | Description |
|---|
name | Yes | Unique skill name, used to invoke the skill |
description | Yes | Tells agents and the skill registry what this skill does |
version | Yes | Semantic version string |
sensitivity | Yes | normal (auto-approvable) or elevated (requires CEO role) |
action_risk | Yes | Risk level — determines the minimum autonomy score required |
inputs | Yes | Named input parameters with types. Append ? for optional fields |
outputs | Yes | Named output fields returned on success |
secrets | Yes | Vault secret keys this skill will read via ctx.secret() (use [] if none) |
permissions | Yes | Declared capability requirements (use [] if none) |
timeout | No | Per-invocation timeout in milliseconds (default: 30000) |
install.requires_secrets | No | Vault keys that must exist before the skill can be installed or enabled |
action_risk values
The action_risk field controls which autonomy band allows this skill to run without additional gating:
| Value | Minimum autonomy score | Examples |
|---|
none | 0 | Reads, retrievals, summarization |
low | 60 | Internal state writes, memory, contacts |
medium | 70 | Outbound communications (email, Slack) |
high | 80 | Calendar writes, commitments on behalf of the CEO |
critical | 90 | Financial, destructive, or irreversible actions |
The handler
Your handler exports a single execute function that receives a SkillContext and returns a SkillResult:
// skills/send-slack-message/handler.ts
import type { SkillHandler, SkillContext, SkillResult } from '../../src/skills/types.js';
export const handler: SkillHandler = {
async execute(ctx: SkillContext): Promise<SkillResult> {
const { channel, text, thread_ts } = ctx.input as {
channel: string;
text: string;
thread_ts?: string;
};
const token = ctx.secret('slack_bot_token');
try {
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ channel, text, thread_ts }),
});
const data = await response.json() as { ok: boolean; ts?: string; error?: string };
if (!data.ok) {
return { success: false, error: `Slack API error: ${data.error}` };
}
return {
success: true,
data: { ts: data.ts, channel },
};
} catch (err) {
return {
success: false,
error: `Failed to post to Slack: ${err instanceof Error ? err.message : String(err)}`,
};
}
},
};
SkillContext reference
The ctx object gives your handler access to inputs, secrets, logging, and caller identity:
| Field | Type | Description |
|---|
ctx.input | Record<string, unknown> | Validated inputs from the manifest schema |
ctx.secret(name) | (name: string) => string | Read a vault secret declared in secrets. Throws if undeclared. |
ctx.log | Logger | Scoped pino logger — use instead of console.log |
ctx.agentPersona | AgentPersona | undefined | Display name, title, email signature of the calling agent |
ctx.caller | CallerContext | undefined | Caller identity — always present for elevated skills |
ctx.contactService | ContactService | undefined | Read-only contact lookups |
ctx.selfEmail | string | undefined | Email address of the active Curia account — use this to filter Curia’s own address from CC lists |
Secrets
Declare secrets your skill needs in the secrets array in skill.json:
"secrets": ["slack_bot_token"]
Then seed the value into the encrypted vault — set it transiently and run the seed script:
SLACK_BOT_TOKEN=xoxb-... pnpm run seed-vault
The script stores it under the canonical key slack_bot_token. Access it in your handler via ctx.secret('slack_bot_token'). Values are resolved from the vault (with an env-var fallback applied at bootstrap); the LLM never sees the value — only your handler does.
If your skill should not be usable until its credential is configured, also declare the key in install.requires_secrets. The registry will then refuse to enable the skill until the secret is in the vault:
"secrets": ["slack_bot_token"],
"install": { "requires_secrets": ["slack_bot_token"] }
Skills must never throw. All error paths must return { success: false, error: '...' } rather than throwing an exception. If an uncaught exception reaches the execution layer, it is caught and wrapped as a generic error — but this prevents proper error messaging to the LLM.
SkillResult
Every skill returns one of two shapes:
// Success
return { success: true, data: { ts: '1234567890.123456', channel: '#general' } };
// Failure
return { success: false, error: 'Could not reach Slack API: connection refused' };
The data field can be any JSON-serializable value. Keep it focused — the execution layer truncates results longer than 200,000 characters before feeding them to the LLM.
MCP skills
Model Context Protocol lets you connect Curia to external tool servers — either hosted services or locally running processes. Once connected, tools from the MCP server appear in the skill registry alongside local skills and are callable the same way.
Configuring MCP servers
Add MCP servers to config/skills.yaml:
# config/skills.yaml
mcp_servers:
- name: google-workspace
transport: http
url: https://mcp-server.example.com/mcp
headers:
Authorization: "Bearer <your-token>"
action_risk: low
permissions:
- workspace:read
- workspace:write
fixed_inputs:
user_google_email: "env:CURIA_GOOGLE_EMAIL" # resolved at startup, invisible to agents
- name: github
transport: http
url: https://api.github.com/mcp
headers:
Authorization: "Bearer ghp_..."
action_risk: low
Supported transports are http (StreamableHTTP, recommended for hosted servers) and stdio (local subprocess).
The optional fixed_inputs map lets you bind parameter values that should always be the same for a given MCP server — for example, which account identity to pass on every call. These values are:
- Resolved at startup from env vars (
"env:VAR_NAME") or used as literals
- Stripped from the tool schema so agents never see the parameter and can’t override it
- Merged into every
callTool invocation automatically by the MCP loader
If an env var referenced in fixed_inputs is missing at startup, Curia raises a McpConfigError and refuses to start — fail-closed, not silent degradation.
At startup, Curia connects to each configured MCP server, discovers available tools via tools/list, and registers them in the skill registry. Agents with allow_discovery: true can find and use MCP tools the same way they discover local skills.
Recommended MCP servers
| Server | Purpose |
|---|
| google_workspace_mcp | Drive, Docs, Sheets — read, write, search |
| modelcontextprotocol/filesystem | Scoped local file access |
| modelcontextprotocol/github | Repo management, issues, PRs |
| modelcontextprotocol/brave-search | Web search for research agents |
Making your skill discoverable
Once your skill directory exists, Curia loads it automatically at startup. To make it available to agents:
- Add it to an agent’s
pinned_skills for guaranteed access
- Or leave it discoverable — agents with
allow_discovery: true can find it via the skill registry when they need it
# In any agent's YAML
pinned_skills:
- send-slack-message
Or let the agent discover it on demand by searching for “Slack” or “post message” through the skill registry.