Blog
Tutorials

Authoring custom skills: templates that stay safe

How Simmetric Chat's custom skills compile a stored prompt template into a spotlighted, untrusted context entry - the whitelist and single-pass rules, the three scoping levels, DLP masking before compile, and the max_skills freemium (3 or unlimited).

Simmetric Team8 min read12/10/2026
Authoring custom skills: templates that stay safe

Instructions you can call, not code you must trust

A custom skill is a reusable prompt template: authored once, invoked repeatedly from the chat as a slash command. The design bet is in the sentence "prompt template": a skill contains no HTTP calls, no file I/O, no conditional logic, no sandbox - there is nothing to escape, so the feature is air-gap-safe by construction. What a skill does contain is a fixed compile pipeline with real boundaries: parameters whitelisted against a schema, one replacement pass, masking before compile, and an injection that arrives as untrusted data.

This article walks the authoring path end to end: how a template is written and compiled, the slash-command grammar, the three scoping levels, the privilege boundary that keeps compiled output honest, and the licensing shape (max_skills - 3 in Community, unlimited in Enterprise).

The template and its rules

A template is a plain string with {{param}} placeholders:

text
Translate to {{targetLang}}: {{input}}

The compile pipeline enforces four rules (pinned at the schema):

  • Whitelist - only keys declared by the skill's inputSchema.properties (plus its default params) substitute. An unknown placeholder stays literal in the output - it is never resolved from environment, system context, or anything else.
  • Single-pass - exactly one replacement pass runs. A substituted value that itself contains {...} marker shapes never re-expands; template-based injection dies here structurally.
  • Coherence - every placeholder in the template must have a matching input-schema entry; create/patch without it is a named 400.
  • Marker rejection - the template's static text (placeholders stripped) must not contain cross-provider tool-call marker shapes; they are rejected at create/patch. Parameter values get their own defense below.

The slash-command grammar

Typing / opens the skills palette (autocomplete); a selected row inserts its slug without sending. On enter, the client parses the arguments and sends the call alongside the typed message:

  • Positional: /translate Ciao mondo - tokens join into the first required field.
  • Pairs: /translate input=Ciao - named fills; quoted spans (input="Ciao mondo") carry spaces.
  • Mixed and defaults: input="Ciao" targetLang=Spanish, or a required key taking its declared default.

Two behaviors make the grammar trustworthy rather than clever:

  • The never-error rule - an unmatched /word, a builtin name, or a disabled row falls through to a normal message: no call, no error, nothing hidden.
  • The server re-validates everything - unknown keys are ignored; the slug is re-resolved server-side with the same scope filter on every invocation (the IDOR guard).

A reserved-slug list (the chat's fixed commands + the builtin skill names) makes shadowing impossible: a custom skill can never take a reserved name.

The request path: resolve, mask, compile, wrap

text
"/slug params" → POST chat stream { message, skillCall }
→ resolveInvocableSkill (server-side, scope-filtered)
→ DLP mask BEFORE compile (params scanned when DLP is active)
→ compileTemplate (whitelisted replacement, one pass) + the spotlight wrapper
→ ONE spotlighted user-level context entry in the orchestrator

The DLP ordering is the load-bearing detail: parameters are masked before compilation. Compiled with the reverse order, template-authored text could hide a parameter from the scanner; with this order, the compiled prompt contains only masked values. (The DLP pipeline itself - the numbered masks, the encrypted originals, the admin unmask - is its own story.)

The privilege boundary

Compiled template output is user-supplied data, not instructions. It enters the conversation as one spotlighted context entry - labeled, budgeted like any other tool result, and pinned by a privilege test: a custom skill can add context but can never add tools, permissions, or registry entries. The transcript keeps the raw typed message; only the compiled prompt enters as skill context.

This is why "no sandbox" is a feature and not a limitation: the template's power surface - what it can influence - is exactly one context entry, and nothing else.

Scoping: three levels, derived server-side

  • Personal - the author alone resolves it (any user with the create permission).
  • Workspace - anyone chatting in that workspace (created by editor+; a viewer gets an explicit 403). The persisted row carries a null user id - a creator-stamped row would leak across workspaces, and the schema closes that class.
  • Global - everyone in the organization (created by admins only).

The client's claimed scope is never trusted: the server derives the level from the persisted shape and filters the resolution per chat. Edits and deletes take effect on the next chat - no hooks, no restarts. Delete is a soft tombstone, and the slug frees immediately: a deleted skill's name is re-creatable on the spot.

The freemium fact: max_skills

The licence limit mirrors the platform's other numeric gates: 3 custom skills in Community, unlimited in Enterprise - the census row F-SKILL-1, sourced at the licence constants:

  • Enforced on CREATE only - gating invocations would interrupt mid-chat for a licensing reason, a rejected design; the count is at provisioning.
  • The count is organization-scoped over the custom rows - builtins and marketplace rows never count.
  • Over the limit, the standard paywall shape names the feature and the tier - and the escape is self-service: every user can delete their own skills to free quota, no admin intervention needed.

That last property is the freemium contract done honestly: the limit is visible in the management UI, the 402 explains itself, and the way out costs no support ticket.

Reading on

The guide carries the full operator reference - endpoints, the form dialog, the test-preview semantics - at /en/docs/custom-skills:

Provenance: Simmetric Chat docs/CUSTOM_SKILLS.md (v0.28.1, Phase 190 SKIL-01..05), packages/shared/src/schemas/skill.schema.ts, license.ts (max_skills) - at the census's pinned docs state (SHA aca5b8ff).

Related articles