Snag for developers

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
FileHolds
.snag/commands.yamlYour commands. Missing: no commands from this file
.snag/agents.yamlAgents Snag should connect to. Missing: none
.snag/packs/*.yamlMore 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: false

Commands

FieldRequiredMeaning
idYesLowercase letters, digits, ., -, _; starts with a letter or digit; 64 characters at most. Unique in the file
triggersYesOne or more phrases. See Triggers
actionYesWhat it does. See Actions
responseNoHow to read the answer back: path, status_url_path, fallback
approval_classNoroutine, consequential or destructive. Missing: runs straight away
confirm_phraseConsequential and destructiveWhat the user repeats to approve. Default: the first trigger's words without its slots
sensitivityNonormal (default), personal or private. Private answers are spoken only after "Say it out loud?"
requiresNoWhat 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

typeFieldsDoes
webhookurl (required), secret_ref, header_auth, mode (sync default, or async)POSTs the match as JSON. See Webhooks
shortcutname (required), inputRuns an iOS Shortcut with the input as text. Needs Snag on screen; in the car it is parked for the phone
agentagent (id or spoken name), verb (ask or dispatch), template (all required)Sends the filled template to the agent
capturedestination (an id, or default), template (required), kind (note default, or task), projectSaves a note or task to one destination
builtinintent: status, attention, park, approve, hold, deny, more, nextOne of Control's own verbs

Templates take {slot} and {transcript} (everything the user said). A template may use only slots its triggers have.

Webhook fieldMeaning
urlhttps://, or http:// to a private address (10, 127, 192.168, 172.16 to 172.31, 100.64 to 100.127, .local, localhost)
secret_refThe name of the Keychain item holding the signing secret (whsec_...). Never the secret itself
header_authA header name (letters, digits, -). Its value is filled in on the phone under header.<command id>
modesync: the workflow answers in the response. async: it answers later

Response

FieldDefaultMeaning
pathspokenDotted path to the text to read back; array indexes as numbers (data.0.text)
status_url_pathnoneWhere 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

ValueShown when
agentsAt least one agent is connected
destination:<id>That destination is connected and on
backend:<id>That agent backend is connected
hosted, accountAccepted; such commands stay hidden in this version

Agents

FieldRequiredMeaning
idYesUnique in the file
spoken_nameYesWhat the user says. For agent_api the names come from the server's GET agents, and this one is not used
aliasesNoOther names
sensitivityNonormal (default), personal or private
connectionYesagent_api, openai or talk
base_urlagent_api, openaiThe server. Same address rule as webhooks
modelopenaiThe model or agent name
roomtalkThe Talk room token (letters and digits, 64 at most)
key_refNoKeychain name of the bearer key. Default: the agent's id
approvalsNotalk: the room carries proposals
ask_timeoutNoSeconds 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;
  • snag is missing or not 1;
  • its shape is wrong (a missing required field, an unknown action type);
  • any command fails: a bad id, a duplicate id, no triggers, a bad trigger, triggers with different slots, a template using an unknown slot, a refused URL, a bad header_auth, an empty shortcut name, agent or template, an empty destination, an unknown builtin, an empty response path, a consequential or destructive command with no confirm phrase, an unknown requirement;
  • any agent fails: a duplicate id, an empty spoken_name, a missing base_url or model, a refused base_url, a missing room.

Any invalid file fails the whole fetch, so a half-broken repo never replaces the version already accepted.

YAML subset

SupportedNot supported
Block maps and lists, - key: value itemsAnchors and aliases
Plain, 'single' and "double" quoted scalarsTags
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

  1. Open the Control tab > Commands > Commands from a repo (or Import commands > Repo).
  2. Pick GitHub or GitLab, or Paste a repo or file address.
  3. 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, .yml or .json, or on raw.githubusercontent.com, is read as one commands file.
  4. For a private repo, sign in or paste a token (below).
  5. Snag reads the files and shows every command and agent with the address it calls. Tap to accept.
HostHow Snag reads
GitHubGET 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 URLGET the URL

Private repos:

HostSign-in
GitHubPaste a fine-grained personal access token with Contents: read. GitHub sign-in with a device code is not set up in this build
GitLabSign 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.

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.

LinkCarries
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