Skip to main content
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

FieldRequiredDescription
nameYesUnique skill name, used to invoke the skill
descriptionYesTells agents and the skill registry what this skill does
versionYesSemantic version string
sensitivityYesnormal (auto-approvable) or elevated (requires CEO role)
action_riskYesRisk level — determines the minimum autonomy score required
inputsYesNamed input parameters with types. Append ? for optional fields
outputsYesNamed output fields returned on success
secretsYesVault secret keys this skill will read via ctx.secret() (use [] if none)
permissionsYesDeclared capability requirements (use [] if none)
timeoutNoPer-invocation timeout in milliseconds (default: 30000)
install.requires_secretsNoVault 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:
ValueMinimum autonomy scoreExamples
none0Reads, retrievals, summarization
low60Internal state writes, memory, contacts
medium70Outbound communications (email, Slack)
high80Calendar writes, commitments on behalf of the CEO
critical90Financial, 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:
FieldTypeDescription
ctx.inputRecord<string, unknown>Validated inputs from the manifest schema
ctx.secret(name)(name: string) => stringRead a vault secret declared in secrets. Throws if undeclared.
ctx.logLoggerScoped pino logger — use instead of console.log
ctx.agentPersonaAgentPersona | undefinedDisplay name, title, email signature of the calling agent
ctx.callerCallerContext | undefinedCaller identity — always present for elevated skills
ctx.contactServiceContactService | undefinedRead-only contact lookups
ctx.selfEmailstring | undefinedEmail 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).

fixed_inputs — binding constants to MCP tools

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.
ServerPurpose
google_workspace_mcpDrive, Docs, Sheets — read, write, search
modelcontextprotocol/filesystemScoped local file access
modelcontextprotocol/githubRepo management, issues, PRs
modelcontextprotocol/brave-searchWeb 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.