---
sidebar_position: 10
title: Function skill
---

# Function skill

Author a custom skill as a single async function. No MCP server to write, no spawn config, no glue — just a `handler({ args })` that returns a JSON-serializable result. Zibby auto-bridges it through MCP at runtime so any Claude/Cursor/Codex node can call it.

For wrapping a full external MCP server, use the same `skill()` factory with a `resolve()` instead of a `handler` — see the MCP skill example at the bottom.

## API

```js
import { skill } from '@zibby/skills';

export const myTool = skill('my_tool', {
  description: 'One-line description shown to the model',
  input: {
    foo: 'string',
    bar: { type: 'number', description: 'Optional knob', required: false },
  },
  handler: async ({ foo, bar = 0 }) => {
    return { result: foo.repeat(bar) };
  },
});
```

| Field | Type | Notes |
|---|---|---|
| `description` | `string` | Shown to the LLM as the tool description |
| `input` | `Record<string, string \| { type, description?, required? }>` | Shorthand schema. String values mean `{ type: <string>, required: true }` |
| `handler` | `async (args) => any` | Return value is `JSON.stringify`'d back to the model |

Calling `skill()` both creates and **registers** the skill in the global registry, so just importing the module is enough to make it available.

## Use in an agent

Once registered, reference by id:

```js
import { WorkflowAgent, WorkflowGraph } from '@zibby/core';
import './skills/my-tool.js'; // import for the side effect

graph.addNode('process', {
  agent: 'claude',
  skills: ['my_tool'],
  prompt: (state) => `Call my_tool with foo="hello", bar=3 and report what you got back.`,
});
```

The tool surfaces to the model as `my_tool` (and to MCP-aware strategies as `mcp__my_tool__my_tool`).

## Output example

For the example above:

```json
{ "result": "hellohellohello" }
```

If `handler` throws, the error message is returned to the model as the tool result so it can recover or retry.

## Wrapping an external MCP server

Same factory, different shape — provide `resolve()` instead of `handler`:

```js
import { skill } from '@zibby/skills';

export const linear = skill('linear', {
  description: 'Linear issue tracker',
  serverName: 'linear',
  allowedTools: ['mcp__linear__*'],
  envKeys: ['LINEAR_API_KEY'],
  resolve() {
    if (!process.env.LINEAR_API_KEY) return null;
    return {
      command: 'npx',
      args: ['-y', '@anthropic/linear-mcp-server'],
      env: { LINEAR_API_KEY: process.env.LINEAR_API_KEY },
    };
  },
});
```

Return `null` from `resolve()` to silently disable the skill when its prerequisites aren't met (no env var, no bin, etc.) — the node still runs, the model just doesn't see those tools.

## Implementation notes

Function skills route through a tiny stdio MCP bridge (`@zibby/core/function-bridge.js`) that the strategy spawns. The bridge imports your skill module, runs the handler in-process, and proxies the result back via MCP — so the agent SDK sees a real MCP server even though you didn't write one.

When you ship a skill in a published package, derive bin/module paths from `import.meta.url`, **not** `require.resolve('@your/pkg/...')`. esbuild emits a `dist/package.json` that makes package self-references resolve to `dist/...` instead of the package root, which silently breaks the lookup. Every Zibby-shipped skill (sentry, lark, jira) uses the `import.meta.url` pattern for this reason.
