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
44 changes: 38 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ server = [
"tower-http/fs",
"tokio/full",
"tokio-util/rt",
"dep:zip",
]

# Native HTTP client features (TLS, HTTP/2)
Expand Down Expand Up @@ -370,6 +371,7 @@ csv = { version = "1.3", optional = true }
dialoguer = { version = "0.12.0", features = ["fuzzy-select", "completion"], optional = true }
dirs = { version = "6.0.0", optional = true }
flate2 = { version = "1", optional = true }
zip = { version = "2", default-features = false, features = ["deflate"], optional = true }
google-cloud-auth = { version = "0.17", default-features = false, features = ["rustls-tls"], optional = true }
google-cloud-secretmanager-v1 = { version = "1.2", optional = true }
google-cloud-token = { version = "0.1", optional = true }
Expand Down
16 changes: 9 additions & 7 deletions docs/content/docs/features/agents.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ GET /v1/containers/{cntr_id}/files/{id}/content # Raw bytes
"allowed_domains": ["pypi.org"]
},
"skills": [
{ "type": "skill_reference", "skill_id": "<uuid>", "version": "latest" },
{ "type": "skill_reference", "skill_id": "skill_…", "version": "latest" },
{
"type": "inline",
"name": "extract-csv",
Expand All @@ -242,12 +242,14 @@ GET /v1/containers/{cntr_id}/files/{id}/content # Raw bytes
}
```

`skill_reference` resolves to a stored skill by UUID. `inline` carries the bundle on
the request itself — today only `media_type: "text/markdown"` is supported (decoded as
the synthetic `SKILL.md`); multi-file (`application/zip`) inline skills reject with
`unsupported_inline_skill_media_type`. `version` accepts `"latest"` only — passing
anything else rejects with `unsupported_skill_version` so a future versioned-skills
release doesn't silently downgrade requests that wanted a pin.
`skill_reference` resolves to a skill stored via [`/v1/skills`](/docs/features/skills) —
`skill_id` accepts a prefixed id (`skill_…`), a bare UUID, or the skill's name slug, all
scoped to the caller's organization. `version` is a positive integer or `"latest"`; omit
it for the skill's default version. An unknown or malformed version rejects with
`unsupported_skill_version`. `inline` carries the bundle on the request itself — today
only `media_type: "text/markdown"` is supported (decoded as the synthetic `SKILL.md`);
multi-file (`application/zip`) inline skills reject with
`unsupported_inline_skill_media_type`.

Containers can also be enumerated:

Expand Down
94 changes: 84 additions & 10 deletions docs/content/docs/features/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ 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.
scripts, references, and assets. You author skills with the
[Agent Skills](https://agentskills.io) `SKILL.md` format, and manage them
through an **OpenAI-compatible `/v1/skills` API** — skills are immutable and
versioned, with a `default_version`/`latest_version` pointer, just like the
OpenAI Skills API (plus Hadrian's multi-tenant ownership extension).

## What a skill looks like

Expand Down Expand Up @@ -102,6 +104,66 @@ Like prompt templates, skills belong to one of four owners:
Chat surfaces all skills the current user can reach. In the admin UI,
manage project skills from **Project → Skills**.

## Managing skills via the API

Skills live under the OpenAI-compatible `/v1/skills` surface. IDs are prefixed
strings (`skill_…`, version ids `skillver_…`) and timestamps are unix seconds.

| Method & path | Purpose |
| ------------------------------------------------ | -------------------------------------------------------- |
| `POST /v1/skills` | Create a skill (publishes version `1`). |
| `GET /v1/skills` | List skills for the current project / accessible owners. |
| `GET /v1/skills/{id}` | Get a skill (with the default version's files). |
| `POST /v1/skills/{id}` | Set the default version (`{ "default_version": "2" }`). |
| `DELETE /v1/skills/{id}` | Delete a skill. |
| `GET /v1/skills/{id}/content` | Download the default version as a zip bundle. |
| `POST /v1/skills/{id}/versions` | Publish a new immutable version. |
| `GET /v1/skills/{id}/versions` | List versions. |
| `GET /v1/skills/{id}/versions/{version}` | Get a specific version. |
| `DELETE /v1/skills/{id}/versions/{version}` | Delete a version. |
| `GET /v1/skills/{id}/versions/{version}/content` | Download a specific version as zip. |

### Versioning

Skills are **immutable** — content never changes in place. To update a skill,
publish a new version with `POST /v1/skills/{id}/versions` (pass `"default":
true` to make it the new default, or repoint later with `POST /v1/skills/{id}`).
The skill projection (name, description, files) always reflects the **default**
version. Versions are numbered `1`, `2`, … and never reused; the default
version cannot be deleted (set another default first).

### Upload formats

Create and version-create accept either:

- **`application/json`** — a `{ path, content }` file array (Hadrian
extension; what the chat UI sends). `owner`, `name`, and `description` may be
supplied explicitly or sniffed from the SKILL.md frontmatter.
- **`multipart/form-data`** — a directory of `files[]` parts, or a single zip
part. `owner_type`/`owner_id`/`name`/`description`/`default` ride along as
form fields.

`GET …/content` returns an `application/zip` bundle.

### Ownership extension

OpenAI's skills are project-scoped; Hadrian keeps `owner_type`/`owner_id`
(organization/team/project/user) as a documented extension. On create, omit
`owner` to default to the API key's scope, or set it explicitly. List with
`?owner_type=&owner_id=` to scope to one owner, or omit both for everything the
caller can access.

### Referencing skills from the Responses API

Attach skills to a `/v1/responses` request via a `skill_reference`:

```json
{ "type": "skill_reference", "skill_id": "skill_…", "version": "latest" }
```

`skill_id` accepts a prefixed id, a bare UUID, or the skill's **name slug**.
`version` is a positive integer or `"latest"`; omit it for the default version.

## Invoking a skill as a user

Two equivalent ways:
Expand Down Expand Up @@ -151,16 +213,28 @@ 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

# Maximum immutable versions retained per skill. Default: 0 (unlimited).
max_skill_versions_per_skill = 0
```

The skill upload request body cap is separate, under `[server]`:

```toml
[server]
# Max request body for skill uploads (zip bundles / multipart). Default: 64 MiB.
skills_body_limit_bytes = 67_108_864
```

## 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.
- **Scripts are read-only in chat.** The chat UI's `Skill` tool can _read_
`scripts/` files but cannot execute them — there is no client-side sandbox.
Skills that require script execution won't work end-to-end in the chat UI.
- **Two invocation paths.** The chat UI's `/<name>` slash command and `Skill`
tool are frontend-only. Separately, the Responses API resolves a
`skill_reference` server-side and mounts the referenced version's files into
the shell-tool sandbox (under `/skills/<id>`), where scripts _can_ run if the
configured runtime supports it.
79 changes: 50 additions & 29 deletions migrations_sqlx/postgres/20250101000000_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1185,8 +1185,12 @@ END $$;
-- (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).
-- The `skills` row holds identity (owner + per-owner unique `name` slug) and
-- mutable version pointers. Each immutable version lives in `skill_versions`
-- with its own files in `skill_version_files`. The only way to change a
-- skill's content is to publish a new version; `POST /v1/skills/{id}` just
-- repoints `default_version_seq`. Per-skill total size cap is 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');
Expand All @@ -1198,26 +1202,20 @@ CREATE TABLE IF NOT EXISTS skills (
id UUID PRIMARY KEY NOT NULL,
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.

P1 In-place mutation of an already-applied migration breaks existing databases

sqlx records the SHA256 checksum of every applied migration in _sqlx_migrations. Because 20250101000000_initial.sql has been modified rather than a new migration added, any database that already ran the old version will fail with "migration was previously applied but its source has changed" on the next sqlx migrate run. This affects every developer's local database, staging, and any CI pipeline that doesn't start from a clean schema. A new migration file (e.g. 20250530000000_skills_versioned.sql) with ALTER TABLE, CREATE TABLE, and a data backfill is needed for existing deployments. The same applies to migrations_sqlx/sqlite/20250101000000_initial.sql.

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

Comment:
**In-place mutation of an already-applied migration breaks existing databases**

`sqlx` records the SHA256 checksum of every applied migration in `_sqlx_migrations`. Because `20250101000000_initial.sql` has been modified rather than a new migration added, any database that already ran the old version will fail with `"migration was previously applied but its source has changed"` on the next `sqlx migrate run`. This affects every developer's local database, staging, and any CI pipeline that doesn't start from a clean schema. A new migration file (e.g. `20250530000000_skills_versioned.sql`) with `ALTER TABLE`, `CREATE TABLE`, and a data backfill is needed for existing deployments. The same applies to `migrations_sqlx/sqlite/20250101000000_initial.sql`.

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

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
-- Per spec: 1..=64 chars, [a-z0-9-]+, no leading/trailing/consecutive hyphens.
-- Immutable per-owner slug; doubles as the name used in skill_reference.
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,
-- Version pointers. API `version` strings are these seqs rendered as decimal.
default_version_seq BIGINT NOT NULL, -- version served by default / GET .../content
latest_version_seq BIGINT NOT NULL, -- highest live version seq
next_version_seq BIGINT NOT NULL DEFAULT 2, -- next seq to assign (v1 created at insert); never reused
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE(owner_type, owner_id, name)
deleted_at TIMESTAMPTZ
);

-- Name uniqueness only among live skills so soft-delete + recreate works.
CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_owner_name_active ON skills(owner_type, owner_id, name) WHERE deleted_at IS NULL;
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;
Expand All @@ -1228,11 +1226,40 @@ DO $$ BEGIN
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 (
-- Immutable skill versions. name/description and all frontmatter flags are
-- snapshotted per version (they derive from that version's SKILL.md). The
-- SkillResource projection surfaces the DEFAULT version's values.
CREATE TABLE IF NOT EXISTS skill_versions (
id UUID PRIMARY KEY NOT NULL,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
version_seq BIGINT NOT NULL, -- public `version` string = this as decimal
-- name snapshots the skill slug; description per spec 1..=1024 chars
name VARCHAR(64) NOT NULL,
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_version_files.byte_size for fast limit checks
total_bytes BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_versions_skill_seq ON skill_versions(skill_id, version_seq);
CREATE INDEX IF NOT EXISTS idx_skill_versions_skill_active ON skill_versions(skill_id) WHERE deleted_at IS NULL;
-- Keyset cursor index for version listing
CREATE INDEX IF NOT EXISTS idx_skill_versions_skill_created ON skill_versions(skill_id, created_at, id);

-- Files bundled into a skill version. Every version must have exactly one row
-- with path = 'SKILL.md' (enforced in service layer). Additional rows hold
-- bundled scripts/references/assets referenced from SKILL.md. Text-only in v1.
CREATE TABLE IF NOT EXISTS skill_version_files (
skill_version_id UUID NOT NULL REFERENCES skill_versions(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,
Expand All @@ -1242,16 +1269,10 @@ CREATE TABLE IF NOT EXISTS skill_files (
-- 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)
PRIMARY KEY(skill_version_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 $$;
CREATE INDEX IF NOT EXISTS idx_skill_version_files_version ON skill_version_files(skill_version_id);

-- ======================================================================
-- OAuth PKCE Authorization Codes
Expand Down
Loading
Loading