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).

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:
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
"/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 orchestratorThe 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:
- Custom skills - the guide
- The plain-language version
- Facts in this post:
FACT-CENSUS.mdrows F-SKILL-1 (the custom skills + themax_skillsfreemium) and F-DLP-1 (the masking layer this path rides)
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).
