Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/content/docs/features/chat-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,22 @@ Tool execution produces artifacts displayed inline:
| `Escape` | Cancel streaming |
| `Ctrl+N` | New conversation |

## Skills

The Skills button in the chat toolbar lets users enable packaged
instructions (following the [Agent Skills](https://agentskills.io) spec)
for the current session. Enabled skills are surfaced to the model as a
single `Skill` tool whose description lists every available skill — the
model picks the right one by description. Users can also invoke a skill
directly by typing `/<skill-name>` in the composer for a typeahead picker.

Import from a GitHub repo or a local folder via the Skills button's
`+` menu. See [Skills](/docs/features/skills) for the full workflow.

## Related Features

<Cards>
<Card title="Skills" href="/docs/features/skills" />
<Card title="Chat Modes" href="/docs/features/chat-modes" />
<Card title="Frontend Tools" href="/docs/features/frontend-tools" />
<Card title="MCP Integration" href="/docs/features/mcp" />
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/features/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"guardrails",
"mcp",
"mcp-agents",
"skills",
"caching"
]
}
166 changes: 166 additions & 0 deletions docs/content/docs/features/skills.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
---
title: Skills
description: Package instructions and bundled files that the model can auto-invoke or users can trigger with a slash command.
---

import { Callout } from "fumadocs-ui/components/callout";

Skills extend what the model can do by packaging reusable instructions as
portable units. Each skill is a `SKILL.md` file plus optional bundled
scripts, references, and assets. Hadrian implements the
[Agent Skills](https://agentskills.io) open specification so skills you
author for Hadrian also work with other compliant agents and vice versa.

## What a skill looks like

Every skill is a directory with at minimum a `SKILL.md` file containing YAML
frontmatter and Markdown instructions:

```markdown
---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
allowed-tools: Bash(python:*) Read
---

# PDF Processing

## When to use this skill

Use this skill when the user needs to work with PDF files.

## How to extract text

1. Run `scripts/extract.py <input.pdf>`
2. Parse the output into a structured summary.
```

Optional bundled files (kept alongside the SKILL.md) become available to the
model on demand:

```text
pdf-processing/
├── SKILL.md # Required: instructions + metadata
├── scripts/ # Optional: executable code
│ └── extract.py
├── references/ # Optional: docs
│ └── REFERENCE.md
└── assets/ # Optional: templates, data files
└── template.md
```

## Frontmatter fields

| Field | Required | Description |
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Unique per owner. 1–64 lowercase ASCII alphanumerics or hyphens, no leading/trailing/consecutive hyphens. |
| `description` | Yes | 1–1024 chars. Drives model invocation — front-load trigger phrases ("Use when…", "when the user mentions…"). |
| `user_invocable` | No | Default `true`. Set to `false` to hide from the `/` menu; useful for background-knowledge skills the model auto-loads. |
| `disable_model_invocation` | No | Default `false`. Set to `true` for skills that should only run when the user explicitly picks them. |
| `allowed_tools` | No | Space-separated list (or YAML array) of tool patterns the skill may use. |
| `argument_hint` | No | Shown during `/` autocomplete, e.g. `[issue-number]`. |
| `metadata` | No | Arbitrary key/value pairs preserved verbatim. |

Any additional keys are preserved in `frontmatter_extra` so unknown
spec-adjacent fields (for third-party agents) round-trip cleanly.

## How the model invokes a skill

Hadrian registers a single `Skill` function tool with the model whenever one
or more skills are enabled for the current session. The tool's description
lists each enabled skill by name and description — the model matches user
intent against those entries and calls `Skill({command: "<name>"})` to load
the full instructions.

The executor runs in the browser:

1. **First call** — `Skill({command: "<name>"})` returns the skill's
`SKILL.md` body plus a manifest of bundled files.
2. **Follow-up calls** — `Skill({command: "<name>", file: "<path>"})`
returns the contents of a specific bundled file referenced in the
SKILL.md.

This matches Claude Code's progressive-disclosure model: the main
instructions enter context once; reference files load only when needed.

<Callout type="info">
Skills are not injected into the system prompt. The directory lives in the `Skill` tool's
description, so unused skills don't pollute every request.
</Callout>

## Ownership & sharing

Like prompt templates, skills belong to one of four owners:

| Owner | Visible to |
| -------------- | --------------------------------------------------------------------- |
| `user` | Only the owning user. |
| `project` | All members of the project. |
| `team` | All members of the team. |
| `organization` | All organization members, subject to RBAC rules on `skill:list/read`. |

Chat surfaces all skills the current user can reach. In the admin UI,
manage project skills from **Project → Skills**.

## Invoking a skill as a user

Two equivalent ways:

- **Slash command.** Type `/<skill-name>` in the chat composer. A popover
lists matching skills; press Enter or Tab to commit. The composer prefixes
your message with a request to use that skill.
- **Skills button.** Click the Skills icon in the chat toolbar to open the
popover. Check a skill to enable it for the session — the model may then
auto-invoke it when relevant.

## Importing skills

Use the Skills button's `+` menu to import.

### From GitHub

Paste a GitHub URL (e.g. `https://github.com/anthropics/skills`) or
`owner/repo`. Hadrian walks the tree via the public GitHub Contents API,
finds every directory containing a `SKILL.md`, and bundles the adjacent
files. Preview the discovered skills and pick which to import.

<Callout type="warn">
The unauthenticated GitHub API allows 60 requests/hour. Hadrian caches directory listings in
`sessionStorage` for 10 minutes to soften this, and surfaces the remaining quota in the import
modal.
</Callout>

### From the filesystem

Choose a folder via the file picker (browsers supporting
`<input webkitdirectory>` — Chromium-based and Safari). Hadrian reads every
file under directories containing a `SKILL.md`.

Binary files are rejected in v1 — skill contents are stored as UTF-8 text.

## Configuration

Per-skill size and per-owner limits live under `[limits.resource_limits]` in
`hadrian.toml`:

```toml
[limits.resource_limits]
# Maximum skills per owner (org/team/project/user). Default: 5000.
max_skills_per_owner = 5000

# Maximum total size of a skill's files in bytes (SKILL.md + bundled).
# Default: 512000 (500 KiB). Set to 0 for unlimited.
max_skill_bytes = 512_000
```

## v1 limitations

- **Text-only files.** Binary assets (images, PDFs) are rejected at the API
boundary. Future work: base64 encoding or object-storage offload.
- **Scripts are read-only.** The agent can _read_ `scripts/` files through
the `Skill` tool but cannot execute them — there is no client-side
sandbox in the Hadrian chat UI. Skills that require script execution
won't work end-to-end.
- **Model-invocation is frontend-only.** The gateway server does not
inspect skills. Custom clients that hit `/v1/responses` directly must
build the `Skill` tool themselves.
77 changes: 77 additions & 0 deletions migrations_sqlx/postgres/20250101000000_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1194,3 +1194,80 @@ DO $$ BEGIN
CREATE TRIGGER update_service_accounts_updated_at BEFORE UPDATE ON service_accounts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
EXCEPTION WHEN duplicate_object THEN null;
END $$;

-- ======================================================================
-- Skills
-- ======================================================================

-- Agent Skills (https://agentskills.io/specification.md). A skill is a
-- packaged set of instructions (SKILL.md) plus optional bundled files
-- (scripts, references, assets) that the model can auto-invoke or the
-- user can invoke via slash-command / button.
--
-- Files are stored inline in skill_files with a per-skill total size cap
-- enforced in the service layer (config: limits.resource_limits.max_skill_bytes).

DO $$ BEGIN
CREATE TYPE skill_owner_type AS ENUM ('organization', 'team', 'project', 'user');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

CREATE TABLE IF NOT EXISTS skills (
id UUID PRIMARY KEY NOT NULL,
owner_type skill_owner_type NOT NULL,
owner_id UUID NOT NULL,
-- Per spec: 1..=64 chars, [a-z0-9-]+, no leading/trailing/consecutive hyphens
name VARCHAR(64) NOT NULL,
-- Per spec: required, 1..=1024 chars
description VARCHAR(1024) NOT NULL,
-- Optional frontmatter fields (NULL = not set)
user_invocable BOOLEAN, -- defaults to true in code
disable_model_invocation BOOLEAN, -- defaults to false in code
allowed_tools JSONB, -- array of tool names
argument_hint VARCHAR(255),
source_url VARCHAR(2048), -- origin URL (e.g. GitHub) if imported
source_ref VARCHAR(255), -- git ref if imported
frontmatter_extra JSONB, -- unknown/forward-compat keys
-- Cached sum of skill_files.byte_size for fast limit checks
total_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE(owner_type, owner_id, name)
);

CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_type, owner_id);
-- Partial index for non-deleted skills (most queries filter by deleted_at IS NULL)
CREATE INDEX IF NOT EXISTS idx_skills_owner_active ON skills(owner_type, owner_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);

DO $$ BEGIN
CREATE TRIGGER update_skills_updated_at BEFORE UPDATE ON skills FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
EXCEPTION WHEN duplicate_object THEN null;
END $$;

-- Files bundled into a skill. Every skill must have exactly one row with
-- path = 'SKILL.md' (enforced in service layer). Additional rows hold
-- bundled scripts/references/assets referenced from SKILL.md.
CREATE TABLE IF NOT EXISTS skill_files (
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
-- Relative path inside the skill directory (e.g. 'SKILL.md', 'scripts/extract.py')
path VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
-- Cached byte length of content for fast total-size aggregation
byte_size BIGINT NOT NULL,
-- MIME type; defaults to 'text/markdown' for SKILL.md, sniffed from
-- extension for others
content_type VARCHAR(127) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(skill_id, path)
);

CREATE INDEX IF NOT EXISTS idx_skill_files_skill ON skill_files(skill_id);

DO $$ BEGIN
CREATE TRIGGER update_skill_files_updated_at BEFORE UPDATE ON skill_files FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
EXCEPTION WHEN duplicate_object THEN null;
END $$;
Comment on lines +1268 to +1273
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing compound index for cursor-based pagination

The pagination queries in both the SQLite and PostgreSQL implementations sort and filter on (owner_type, owner_id, created_at, id), but the migration only creates idx_skills_owner ON skills(owner_type, owner_id). Without created_at and id in the index, every paginated request must sort the filtered result set in memory. For skill counts beyond a few hundred per owner, this will become a full-table sort per page request.

Consider adding a composite index:

CREATE INDEX IF NOT EXISTS idx_skills_owner_created ON skills(owner_type, owner_id, created_at DESC, id DESC) WHERE deleted_at IS NULL;

The same gap exists in the SQLite migration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: migrations_sqlx/postgres/20250101000000_initial.sql
Line: 1268-1273

Comment:
**Missing compound index for cursor-based pagination**

The pagination queries in both the SQLite and PostgreSQL implementations sort and filter on `(owner_type, owner_id, created_at, id)`, but the migration only creates `idx_skills_owner ON skills(owner_type, owner_id)`. Without `created_at` and `id` in the index, every paginated request must sort the filtered result set in memory. For skill counts beyond a few hundred per owner, this will become a full-table sort per page request.

Consider adding a composite index:
```sql
CREATE INDEX IF NOT EXISTS idx_skills_owner_created ON skills(owner_type, owner_id, created_at DESC, id DESC) WHERE deleted_at IS NULL;
```
The same gap exists in the SQLite migration.

How can I resolve this? If you propose a fix, please make it concise.

60 changes: 60 additions & 0 deletions migrations_sqlx/sqlite/20250101000000_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,63 @@ CREATE INDEX IF NOT EXISTS idx_service_accounts_slug ON service_accounts(slug);
-- Partial indexes for non-deleted service accounts (most queries filter by deleted_at IS NULL)
CREATE INDEX IF NOT EXISTS idx_service_accounts_org_active ON service_accounts(org_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_service_accounts_org_slug_active ON service_accounts(org_id, slug) WHERE deleted_at IS NULL;

-- ======================================================================
-- Skills
-- ======================================================================

-- Agent Skills (https://agentskills.io/specification.md). A skill is a
-- packaged set of instructions (SKILL.md) plus optional bundled files
-- (scripts, references, assets) that the model can auto-invoke or the
-- user can invoke via slash-command / button.
--
-- Files are stored inline in skill_files with a per-skill total size cap
-- enforced in the service layer (config: limits.resource_limits.max_skill_bytes).
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY NOT NULL,
owner_type TEXT NOT NULL CHECK (owner_type IN ('organization', 'team', 'project', 'user')),
owner_id TEXT NOT NULL,
-- Per spec: 1..=64 chars, [a-z0-9-]+, no leading/trailing/consecutive hyphens
name TEXT NOT NULL,
-- Per spec: required, 1..=1024 chars
description TEXT NOT NULL,
-- Optional frontmatter fields (NULL = not set)
user_invocable INTEGER, -- bool (0/1); defaults to true in code
disable_model_invocation INTEGER, -- bool (0/1); defaults to false in code
allowed_tools TEXT, -- JSON array of tool names
argument_hint TEXT,
source_url TEXT, -- origin URL (e.g. GitHub) if imported
source_ref TEXT, -- git ref if imported
frontmatter_extra TEXT, -- JSON object, unknown/forward-compat keys
-- Cached sum of skill_files.byte_size for fast limit checks
total_bytes INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
deleted_at TEXT,
UNIQUE(owner_type, owner_id, name)
);

CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_type, owner_id);
-- Partial index for non-deleted skills (most queries filter by deleted_at IS NULL)
CREATE INDEX IF NOT EXISTS idx_skills_owner_active ON skills(owner_type, owner_id) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);

-- Files bundled into a skill. Every skill must have exactly one row with
-- path = 'SKILL.md' (enforced in service layer). Additional rows hold
-- bundled scripts/references/assets referenced from SKILL.md.
CREATE TABLE IF NOT EXISTS skill_files (
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
-- Relative path inside the skill directory (e.g. 'SKILL.md', 'scripts/extract.py')
path TEXT NOT NULL,
content TEXT NOT NULL,
-- Cached byte length of content for fast total-size aggregation
byte_size INTEGER NOT NULL,
-- MIME type; defaults to 'text/markdown' for SKILL.md, sniffed from
-- extension for others
content_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY(skill_id, path)
);

CREATE INDEX IF NOT EXISTS idx_skill_files_skill ON skill_files(skill_id);
2 changes: 2 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,11 +429,13 @@ impl AppState {
);

let max_expr_len = config.auth.rbac.max_expression_length;
let max_skill_bytes = config.limits.resource_limits.max_skill_bytes;
let services = services::Services::with_event_bus(
db.clone(),
file_storage,
event_bus.clone(),
max_expr_len,
max_skill_bytes,
);
(Some(db), Some(services))
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ pub(crate) async fn run_bootstrap(explicit_config_path: Option<&str>, dry_run: b
let file_storage: std::sync::Arc<dyn services::FileStorage> =
std::sync::Arc::new(services::DatabaseFileStorage::new(db.clone()));
let max_cel = config.auth.rbac.max_expression_length;
let services = services::Services::new(db.clone(), file_storage, max_cel);
let max_skill_bytes = config.limits.resource_limits.max_skill_bytes;
let services = services::Services::new(db.clone(), file_storage, max_cel, max_skill_bytes);

let api_key_prefix = config.auth.api_key_config().generation_prefix();
let mut summary = Vec::new();
Expand Down
1 change: 1 addition & 0 deletions src/cli/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ pub(crate) async fn run_worker(
db.clone(),
file_storage,
config.auth.rbac.max_expression_length,
config.limits.resource_limits.max_skill_bytes,
);
let vector_stores_service = Arc::new(services.vector_stores.clone());

Expand Down
Loading
Loading