Docs/Gage the AI Harness/Authoring an Atlas Skill
GageConcept

Authoring an Atlas Skill

This chapter is for a developer or administrator who needs to author a new Atlas skill and understand exactly what happens on the server when they do.

Updated 29 Oct 2026Timebase Docs

Overview#

This chapter is for a developer or administrator who needs to author a new Atlas skill and understand exactly what happens on the server when they do.

For the end-user view of skills - what the five built-in skills do, how to use the Skills page, and the troubleshooting table - see Atlas Skills. That article covers the same ground from the outside; this one goes a layer deeper, into the classes and endpoints that back the page.

1. What a Skill Is at Runtime#

Start here, because it corrects the most common wrong assumption a developer coming from a different agent framework will bring: a skill is not a sandboxed capability grant. It is a markdown file the calling agent reads, on demand, and then acts on using whatever tools that agent already has.

SkillToolProvider exposes exactly two tools to an agent: ListSkills, which lists skills by keyword, and GetSkill, which returns one skill's full markdown body. (Before September 2026 they were ListSkills and GetSkill; an external MCP client still using those names finds nothing.) Writing a skill is a separate provider, SkillWriteToolProvider, with CreateSkill and UpdateSkill, administrators only, and it is what the curator agent and the create-skill skill use to save what they produce. The class's own doc comment states the architecture plainly:

"Skills are not invoked through this provider - agents read the instructions via GetSkill and then execute the skill's steps using their own real tools. Tool-scope enforcement is handled at agent creation time by ToolScopeResolver."#

Three things follow from that, and they settle most of the questions that come up while authoring:

  • A skill's settings are advisory. GetSkill returns the skill's name, body and, if present, an **Allowed tools:** ... line as a single string for the model to read. Tool scope is applied on the agent, not here.
  • Tool scope is decided once, at agent creation, not per skill. ToolScopeResolver.Resolve resolves an agent's tools: frontmatter list against the registered IToolProviders at the point the agent is built. A skill loaded mid-conversation by that agent cannot narrow or widen that already-resolved tool set - it is loaded as text into the conversation, not as a new scope.
  • A skill only reaches an agent that was given SkillToolProvider itself. Of Atlas's built-in agents, gage and curator list SkillToolProvider in their tools: frontmatter; the modelling specialists (architect, builder, mapper, investigator, reviewer) and guide do not, so they never see ListSkills or GetSkill at all, regardless of what any skill's own frontmatter says.

The practical consequence for you as an author: write every skill assuming it will run with the full, unrestricted tool set of whichever agent picks it up. If you need a hard boundary - "this workflow must never write, only read" - that boundary has to be enforced by giving a different, narrowly-scoped agent that skill's job, or by writing the check into the skill's own instructions and trusting the model to follow it. Neither the skill's frontmatter nor the Skills page's checkboxes create an enforced boundary. Section 2 below is specific about which fields those are.

2. The SKILL.md Format#

A skill is a directory containing one required file, SKILL.md: YAML frontmatter, a --- delimiter, then a markdown body. SkillParser.SplitFrontmatter does the split; SkillYaml is the complete set of frontmatter keys the parser recognises:

Frontmatter key YAML alias Manifest property Default Enforced at runtime?
Description description SkillManifest.Description falls back to the skill's directory name Yes - it is the only thing ListSkills matches against, so a vague description means the assistant never picks the skill up on its own.
User-invocable user-invocable SkillManifest.UserInvocable true Appears as a label: ListSkills appends [user-invocable] to a matching entry's listing.
Disable model invocation disable-model-invocation SkillManifest.ModelInvocable (inverted) false (model-invocable by default) Recorded on the manifest and returned on the API. A skill carrying it is still listed by ListSkills and loadable by GetSkill, so name the skill explicitly where you want it run only on request.
Allowed tools allowed-tools SkillManifest.AllowedTools null Appended to the prompt text by GetSkill as **Allowed tools:** Read, Bash, ..., so the model reads it as part of the instructions. Tool scope itself is set on the agent - see Section 1.
Context context SkillManifest.ForkContext (true when the value is fork, case-insensitive) false Recorded on the manifest and round-tripped through the API. A skill runs in the invoking agent's context.

None of the three behavioural checkboxes on the Skills page - Users can invoke this directly, Gage can use this on its own, Runs in its own isolated context - change what the calling agent is allowed to do. Only the first (user-invocable) reaches the runtime at all, and only as the [user-invocable] text suffix described above. Treat all three, and allowed-tools, as documentation of your intent for the next person reading the skill, not as a control.

SkillManifest additionally carries Name (the directory name - a skill has no separate name: frontmatter key to disagree with it, unlike an agent), DirectoryPath, IsUserDefined, and ReferenceFileCount (every file under the directory except SKILL.md itself, counted recursively - SkillParser.CountReferenceFiles,). Body is null until a skill is actually loaded (SkillParser.ParseMetadata only reads frontmatter; SkillParser.ParseFull, reads the body too) - this is the lazy-load SkillRegistry.LoadSkill implements.

Minimal frontmatter#

---
description: "One line - the only text the assistant sees when deciding whether this skill fits a request"
user-invocable: true
disable-model-invocation: false
---
# Your Skill Name

Numbered steps, written the way you would brief a colleague...

This is exactly the shape of the five shipped skills. create-domain, model-domain, create-agent and create-skill use only description, user-invocable, and disable-model-invocation; explain-ontology is the one skill that sets disable-model-invocation: true - per the table above, this does not stop the assistant from picking it up on a general request, it only means a human reading the file should not expect it to volunteer itself. None of the five ships an allowed-tools or context: fork key, so if you add either, you are exercising a path the shipped skills do not exercise.

3. Reference Files#

A skill's directory can hold more than SKILL.md: lookup tables, specifications, or scripts. The authoring routes in and the filesystem rules in govern this.

  • Size cap: 10 MB per file. SkillFileStore.MaxFileSizeBytes is checked while streaming the upload, not after buffering the whole file; a file that exceeds it is rejected with "File '<name>' exceeds the 10 MB per-file limit.".
  • Two extension allow-lists, unioned. Documents and data - .md .txt .json .csv .xml .yaml .yml .pdf
    • and scripts - .py .sh .js .ps1. Anything else is refused with the extension named in the message.
  • SKILL.md is reserved. It can never be uploaded or deleted as a reference file (for upload, for delete) - it is the skill's own definition file, not a reference file.
  • Filenames are sanitised, not just extension-checked. A name containing /, \, or .. is rejected outright rather than stripped, and the fully resolved path is re-checked to still sit directly inside the skill's own directory as defense-in-depth - the same containment guarantee ScriptToolProvider.ExecuteScript relies on when it later resolves a script by bare filename.
  • A batch upload is all-or-nothing. SaveAsync validates every filename in the batch before writing anything, and if a later file in the batch throws (e.g. it hits the size cap mid-stream), every file this call already wrote is deleted before the exception propagates. One bad file in a batch of ten means none of the ten land.

A script is executed by name from inside its own skill's directory (ScriptToolProvider.ExecuteScript,), with a 60-second default timeout and its own independent path-containment check - belt-and-braces with the store's own check at upload time.

4. The Security Position#

Important

a skill reference file can be a script, and Atlas runs it on the server. Anyone who can reach the skill-authoring routes can therefore run code on the Atlas host, limited only by the privileges of the Atlas process and a 60-second execution timeout. Treat skill authoring as an administrative capability, not an editing one.

Two things people expect to contain this do not:

  • The file-extension allow-list and the size cap prevent accidents, not attacks. A short file that shells out can do anything a blocked extension could have done directly.
  • Containment applies to where a file lands, not to what it does. An upload is guaranteed to land inside its own skill's directory and nowhere else. Nothing constrains an accepted script once it runs.

The control that does work is who can reach the authoring routes, and Atlas applies it by default. The ordinary chat surface requires any authenticated user; creating, updating or deleting a skill, and uploading files to one, requires the pulse:administrator role. Grant that role only to people you would trust with a shell on the server.

If you are building your own host on the Timebase agent runtime rather than using Atlas, you must apply that split yourself. The default endpoint mapping exposes every route, skill authoring and file upload included, with no authorization at all. Never use it on a multi-user deployment.

5. Where Skills Live on the Server, and the Traps#

Two directories are configured into AgentOptions.SkillDirectories, in this order:

// Built-in skills shipped with the binary
options.SkillDirectories.Add(
    Path.Combine(AppContext.BaseDirectory, "skills"));

// User-defined skills: user-authored, so they stay under Config
var userSkillsPath = Path.Combine(configPath, "skills");
Directory.CreateDirectory(userSkillsPath);
options.SkillDirectories.Add(userSkillsPath);

SkillRegistry treats the last configured directory as the one user-created skills are written to, and stamps every skill found elsewhere as not user-defined. The authoring routes in refuse to read, update, delete, or accept file uploads for any skill where IsUserDefined is false - a built-in skill is untouchable through the API regardless of caller role.

Three things follow from this layout:

Built-in skills load from the deployed files, not from source. The first directory is AppContext.BaseDirectory/skills, the build output folder. The build copies files forward without removing ones whose source has gone, so on a development machine a skill you renamed or removed in source can keep loading from the previous build output. Clean the build output and rebuild so the list matches the source.

Skills are held outside version control. The skills and agents directories are added to the ontology repository's .gitignore at startup, and any previously tracked copies are untracked from the index on every boot with the working tree left untouched. The registries read both directories straight off the filesystem, and keeping them out of the model history keeps prompt edits separate from model changes.

Plan a backup around that. If you overwrite a skill's instructions through the Skills page or the raw-text API, the previous version is gone. Keep your own copy of anything you care about before you edit it.

Built-in skills never appear on the Skills page. GET /app/v1/agent/skills returns every skill, built-in and user-defined, with IsUserDefined on each entry (SkillEndpoints.GetSkills, SkillInfoResponse.IsUserDefined,). The client is what filters to user-defined skills before rendering the list - the wire contract itself carries every skill. If you are debugging "why doesn't my new built-in skill show up on the page," the answer is: it is working correctly, and you should verify it with ListSkills/GetSkill through chat, or by calling the list endpoint directly and checking isUserDefined, rather than expecting it on that page.

Note

agents live in {Config}/agents and skills in {Config}/skills, where Config is the Atlas store path. In Docker the platform Compose file sets Config=/atlas/config. A relative Config value is anchored to the install directory rather than the working directory.

6. Worked Example: Author, Attach, Invoke, Verify#

This walks the REST surface directly - the same calls the Skills page's UI makes - so every step is traceable to a route. Substitute your Atlas host and an administrator session cookie/bearer for $ATLAS.

1. Create the skill. POST /app/v1/agent/skills, body shaped as CreateSkillRequest:

curl -X POST "$ATLAS/app/v1/agent/skills" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "line-audit",
    "description": "Audit a domain for unreachable types and instances missing required properties",
    "instructions": "# Line Audit\n\n1. Call ListDomains and ask which domain to audit if not given.\n2. Call ListOntologyNodes for the domain. Flag any node with no parent that never appears as a relationship target.\n3. Read the attached checklist.md reference file for the full list of checks to run, and apply each one.\n4. Report every issue as a numbered list with severity (Warning/Error). If none, say \"Audit complete - no issues found.\"",
    "userInvocable": true,
    "modelInvocable": true
  }'

The name is validated against ^[a-z0-9][a-z0-9-]*$ - the same rule an agent name follows. A name colliding with a built-in (model-domain, create-domain, explain-ontology, create-agent, create-skill) is refused with "'<name>' is a built-in skill and cannot be used for a new skill." before any directory is even created. A successful call writes SKILL.md to the user skills directory and calls registry.Reload() - the skill is live for the very next chat turn, no restart.

2. Attach a reference file. POST /app/v1/agent/skills/{name}/files, multipart form upload:

curl -X POST "$ATLAS/app/v1/agent/skills/line-audit/files" \
  -F "files=@checklist.md"

checklist.md passes the document/data extension check and is not SKILL.md, so it lands directly inside the skill's directory. Note this only puts the file on disk next to SKILL.md - it does not put its contents in front of the assistant automatically. That is why step 1's instructions explicitly tell the assistant to read checklist.md by name; without that line in the skill body, the file sits there unused.

3. Invoke it from an agent. Any agent with SkillToolProvider in its tools: frontmatter can discover and load it - in the shipped set, that is gage and curator. Send a chat turn through POST /app/v1/agent/chat, shaped as AgentChatRequest:

curl -X POST "$ATLAS/app/v1/agent/chat" \
  -H "Content-Type: application/json" \
  -d '{
    "threadId": "test-thread-1",
    "message": "Run the line-audit skill on the packaging-line domain.",
    "agentId": "gage"
  }'

Internally the model calls ListSkills (or is routed directly since the request named the skill), then GetSkill("line-audit"), which returns the markdown body plus, since no allowed-tools was set on this skill, no **Allowed tools:** suffix at all (only appends that line if (skill.AllowedTools is { Count: > 0 })). The model then executes the steps itself, using whatever tools the gage agent's own tools: list already granted it (Section 1) - nothing about this skill's own frontmatter widened or narrowed that set.

4. Verify. Three independent checks, each tied to a concrete endpoint:

  • GET /app/v1/agent/skills should list line-audit with isUserDefined: true.
  • GET /app/v1/agent/skills/line-audit/files should list checklist.md with isExecutable: false.
  • The chat response itself should show the assistant citing the checklist's checks in its report - if it does not, the most common cause is the instructions never named the file, so re-read step 1's body text against Section 3's note above.

To clean up, DELETE /app/v1/agent/skills/line-audit removes the whole directory recursively, reference files included - the same operation the Skills page's delete confirmation performs, which is why that dialog reports how many reference files will go with it (SkillManifest.ReferenceFileCount, populated).

esc
↑↓ navigate · ↵ open · ⌘K anywhere