Commands in a repo
Keep Snag commands, and the agents they call, in a .snag/ folder in a GitHub or GitLab repo. Review them like any other change; Snag reads them from the phone and writes nothing back.
Commands and agents are part of Snag Control, a Pro feature. See Plans and Commands.
Layout
your-repo/
.snag/
commands.yaml # snag: 1, commands: [...]
agents.yaml # snag: 1, agents: [...] optional
packs/
running.yaml # snag: 1, commands: [...] optional, any number| File | Holds |
|---|---|
.snag/commands.yaml | Your commands. Missing: no commands from this file |
.snag/agents.yaml | Agents Snag should connect to. Missing: none |
.snag/packs/*.yaml | More commands, one file per pack (.yaml or .yml). Each file's name is the pack's name |
Every file starts with snag: 1. A file is YAML (the subset below) or JSON.
A full example
.snag/commands.yaml:
snag: 1
commands:
# n8n: answers in the response
- id: miles
triggers: ["log {miles} miles", "i ran {miles} miles"]
action:
type: webhook
url: https://n8n.example.com/webhook/snag-miles
secret_ref: n8n.miles
response:
path: spoken
fallback: Logged {miles} miles.
# Zapier: Catch Hook answers at once, the real answer comes later
- id: zap.expense
triggers:
- expense {amount} for {what}
action: {type: webhook, url: "https://hooks.zapier.com/hooks/catch/123/abc/", mode: async}
approval_class: consequential
confirm_phrase: log the expense
# a Shortcut on the phone
- id: focus
triggers: [focus for {minutes} minutes]
action:
type: shortcut
name: Focus timer
input: "{minutes}"
# an ask to an agent
- id: coach.week
triggers: [how was my week]
action:
type: agent
agent: coach
verb: ask
template: Summarise my training week in two sentences.
sensitivity: private
# a task to a destination
- id: shop.add
triggers: ["we need {item}", "we're out of {item}"]
action:
type: capture
destination: default
kind: task
template: "{item}"
project: Shopping
response:
fallback: Added.
# one of Control's own verbs
- id: waiting
triggers: [what's waiting]
action: {type: builtin, intent: attention}.snag/agents.yaml:
snag: 1
agents:
- id: ops
spoken_name: ops
aliases: [operations]
connection: agent_api
base_url: https://agent.example.com/snag/v1/
key_ref: agent.ops
- id: helper
spoken_name: helper
connection: openai
base_url: https://llm.example.com/v1
model: <model name>
key_ref: agent.helper
- id: pa
spoken_name: pa
connection: talk
room: <talk room token>
approvals: falseCommands
| Field | Required | Meaning |
|---|---|---|
id | Yes | Lowercase letters, digits, ., -, _; starts with a letter or digit; 64 characters at most. Unique in the file |
triggers | Yes | One or more phrases. See Triggers |
action | Yes | What it does. See Actions |
response | No | How to read the answer back: path, status_url_path, fallback |
approval_class | No | routine, consequential or destructive. Missing: runs straight away |
confirm_phrase | Consequential and destructive | What the user repeats to approve. Default: the first trigger's words without its slots |
sensitivity | No | normal (default), personal or private. Private answers are spoken only after "Say it out loud?" |
requires | No | What must be connected for the command to show. See Requirements |
Triggers
- Words and
{slot}names, e.g.log {miles} miles. - Slot names: lowercase letters, digits and
_, starting with a letter or_, 32 characters at most. - At least one word. No two slots side by side. No slot twice.
- Every trigger of a command must have the same slots.
- Matching takes the whole utterance, words in order, ignoring case and punctuation. A slot takes one or more words.
- A leading "snag" and a trailing "please" are ignored.
- When several commands match, the one with the most literal words wins, then the one whose slots took the fewest words.
Actions
type | Fields | Does |
|---|---|---|
webhook | url (required), secret_ref, header_auth, mode (sync default, or async) | POSTs the match as JSON. See Webhooks |
shortcut | name (required), input | Runs an iOS Shortcut with the input as text. Needs Snag on screen; in the car it is parked for the phone |
agent | agent (id or spoken name), verb (ask or dispatch), template (all required) | Sends the filled template to the agent |
capture | destination (an id, or default), template (required), kind (note default, or task), project | Saves a note or task to one destination |
builtin | intent: status, attention, park, approve, hold, deny, more, next | One of Control's own verbs |
Templates take {slot} and {transcript} (everything the user said). A template may use only slots its triggers have.
| Webhook field | Meaning |
|---|---|
url | https://, or http:// to a private address (10, 127, 192.168, 172.16 to 172.31, 100.64 to 100.127, .local, localhost) |
secret_ref | The name of the Keychain item holding the signing secret (whsec_...). Never the secret itself |
header_auth | A header name (letters, digits, -). Its value is filled in on the phone under header.<command id> |
mode | sync: the workflow answers in the response. async: it answers later |
Response
| Field | Default | Meaning |
|---|---|---|
path | spoken | Dotted path to the text to read back; array indexes as numbers (data.0.text) |
status_url_path | none | Where the response names an https URL to poll for a later answer |
fallback | "Sent." | Said when the action worked and returned nothing to read. Takes {slot} |
Requirements
| Value | Shown when |
|---|---|
agents | At least one agent is connected |
destination:<id> | That destination is connected and on |
backend:<id> | That agent backend is connected |
hosted, account | Accepted; such commands stay hidden in this version |
Agents
| Field | Required | Meaning |
|---|---|---|
id | Yes | Unique in the file |
spoken_name | Yes | What the user says. For agent_api the names come from the server's GET agents, and this one is not used |
aliases | No | Other names |
sensitivity | No | normal (default), personal or private |
connection | Yes | agent_api, openai or talk |
base_url | agent_api, openai | The server. Same address rule as webhooks |
model | openai | The model or agent name |
room | talk | The Talk room token (letters and digits, 64 at most) |
key_ref | No | Keychain name of the bearer key. Default: the agent's id |
approvals | No | talk: the room carries proposals |
ask_timeout | No | Seconds before an ask goes In flight. Minimum 1 |
Keys are never in the repo. Save each one on the phone in Settings > Advanced > Use my own infrastructure > Direct endpoints > Keys, under its key_ref name.
There is no dispatch field: an openai agent from a repo takes questions only. To give it jobs, add it under Direct endpoints with dispatch on.
See Snag agent API, OpenAI-compatible endpoints and Nextcloud Talk agents for what each connection expects.
Checks
A file is refused whole, with the file named in the error, when:
- it is not valid YAML (subset below) or JSON;
snagis missing or not1;- its shape is wrong (a missing required field, an unknown action
type); - any command fails: a bad
id, a duplicateid, no triggers, a bad trigger, triggers with different slots, a template using an unknown slot, a refused URL, a badheader_auth, an empty shortcut name, agent or template, an empty destination, an unknownbuiltin, an empty responsepath, a consequential or destructive command with no confirm phrase, an unknown requirement; - any agent fails: a duplicate
id, an emptyspoken_name, a missingbase_urlormodel, a refusedbase_url, a missingroom.
Any invalid file fails the whole fetch, so a half-broken repo never replaces the version already accepted.
YAML subset
| Supported | Not supported |
|---|---|
Block maps and lists, - key: value items | Anchors and aliases |
| Plain, 'single' and "double" quoted scalars | Tags |
Integers, floats, true/false, null/~ | Several documents |
Flow [a, b] and {a: 1} | Complex keys |
| and > block scalars, comments, a leading --- | Tabs for indentation |
Quote any value that starts with {, [, *, &, !, |, >, @ or a backtick. In a flow list, quote items with braces: ["log {miles} miles"]. In a block list (- log {miles} miles) no quotes are needed.
Linking a repo
- Open the Control tab > Commands > Commands from a repo (or Import commands > Repo).
- Pick GitHub or GitLab, or Paste a repo or file address.
- Paste a repo URL. Add
/tree/<branch>(GitHub) or/-/tree/<branch>(GitLab) to pin a branch or tag; otherwise the default branch is read. A URL ending in.yaml,.ymlor.json, or onraw.githubusercontent.com, is read as one commands file. - For a private repo, sign in or paste a token (below).
- Snag reads the files and shows every command and agent with the address it calls. Tap to accept.
| Host | How Snag reads |
|---|---|
| GitHub | GET https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref=<branch> with Accept: application/vnd.github.raw+json; the packs folder as a listing |
| GitLab (gitlab.com or your own) | GET {server}/api/v4/projects/{project}/repository/files/{path}/raw?ref=<branch>; packs via repository/tree?path=.snag/packs |
| A file URL | GET the URL |
Private repos:
| Host | Sign-in |
|---|---|
| GitHub | Paste a fine-grained personal access token with Contents: read. GitHub sign-in with a device code is not set up in this build |
| GitLab | Sign in to GitLab: OAuth with scope read_repository. On a server where Snag is not registered, add an application in GitLab (redirect URI snag://oauth/gitlab, not confidential, scope read_repository) and paste its Application ID. Or paste a project or personal access token |
Tokens are kept in this iPhone's Keychain. Read-only access is enough.
Updates
- Linked repos are checked each time Snag opens, and with Check now.
- A change that calls no new address is applied as it is.
- A change that calls a new address waits in Review changes, listing what is new, changed and removed and which addresses are new. The accepted version keeps working until the change is accepted.
- The first link always shows its addresses before anything runs.
- A failed fetch, or any invalid file, keeps the last accepted version and shows the error.
- Unlink removes the repo's commands and agents.
Share links
A commands file can travel as a link. Opening it in Snag adds the file as a link waiting to be accepted, so it still needs one tap.
| Link | Carries |
|---|---|
snag://commands?data=<base64url> | The file itself: JSON ({"snag": 1, "name": "...", "commands": [...]}), base64url-encoded without padding |
snag://commands?url=<https URL> | The address of a commands file (YAML or JSON). Only https |
Make a data link:
printf '%s' '{"snag":1,"name":"Running","commands":[{"id":"miles","triggers":["log {miles} miles"],"action":{"type":"webhook","url":"https://n8n.example.com/webhook/snag-miles"}}]}' \
| base64 | tr '+/' '-_' | tr -d '=\n' | sed 's|^|snag://commands?data=|'A linked file is validated exactly like a repo file.
Back to Snag for developers