Claude Skill

add-dial-tool

Give chosen NanoClaw agents a real phone number as a container tool — the `dial` CLI baked into the agent image plus OneCLI credential injection for api.getdial.ai, scoped per agent, so the agents you pick can send SMS, place AI voice calls, and receive verification codes from in

LLM Mart · 0 points · 15 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download nanocoai-nanoclaw-.claude_skills_add-dial-tool-6137e0e.zip · 11 KB
Part of nanocoai/nanoclaw — 49 skills

Install

skills CLI npx skills add https://github.com/nanocoai/nanoclaw/tree/main/.claude/skills/add-dial-tool
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install nanocoai-nanoclaw@llmmart
Git git clone https://github.com/nanocoai/nanoclaw.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole nanocoai/nanoclaw collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Add Dial Tool

Installs Dial as a container tool: the dial CLI on the agent's PATH, the dial-cli skill so the agent knows how to drive it, and an OneCLI credential so in-container calls are injected keyless. Independent of the Dial channel (/add-dial) — install this alone. Idempotent: re-run it to change which agents may use Dial.

This tool spends money and reaches real people. An agent with Dial access can text and call any number and buy more numbers, billed to the Dial account. The CLI and the skill file land in every agent's container, but the key is injected per agent by OneCLI, so the operator chooses which agents get it. Every other agent gets an OneCLI block rule and sees 403 blocked_by_policy if it tries.

Run this from the NanoClaw repo on the host (not from a chat with an agent — the container can't install itself). The mechanical steps carry nc: directive fences: an agent reads the prose and applies them, and a parser can apply them deterministically from the same document. Every directive is idempotent, so the whole skill is safe to re-run; anything a parser can't apply falls back to the prose beside it.

Pre-flight

OneCLI is required for credential injection — without it there is no way to hand the key to a container without putting it in an env var. This must succeed before anything else runs:

command -v onecli >/dev/null

If it fails, tell the user to run /init-onecli first, then retry. Stop here.

Calls this setup makes to Dial identify the install. The dial CLI prepends DIAL_USER_AGENT to its own token, so the account's requests stay attributable to this NanoClaw install in Dial's server-side logs. Resolve the token once (nanoclaw/<version>; an unreadable package.json degrades to nanoclaw/unknown rather than blocking the install):

node -p "'nanoclaw/'+(require('./package.json').version||'unknown')" 2>/dev/null || echo nanoclaw/unknown

Prefix every dial command below with DIAL_USER_AGENT={{dial_ua}}.

Choose which agents may use Dial

List the agent groups (the NanoClaw service must be running — ncl talks to it over its socket):

ncl groups list --json | jq -r 'if (.data|length)==0 then "no agent groups yet" else [.data[] | "\(.id) (\(.name))"] | join(", ") end'

Ask the operator which of them may use Dial. Say plainly what they are granting, and ask even when there is a single agent:

Agents on this install: {{agent_groups}}. Giving an agent Dial lets it text and call any number and buy numbers, billed to your Dial account. Agents you leave out are blocked at the gateway (reversible by running /add-dial-tool again). Agents created after this run have Dial until the next run.
Which agents may use Dial? Enter agent ids separated by commas with no spaces (the `ag-…` column), `all` for every agent, or `none` to install the tool with every agent blocked for now.

all and none cannot be mixed with ids, and an empty answer is never "everyone". A typo must not silently open or close anything, so every id named must be a real agent group:

for w in $(printf '%s' '{{dial_agents}}' | tr ',' ' '); do case "$w" in all|none) ;; *) ncl groups list --json | jq -e --arg id "$w" '.data[] | select(.id==$id)' >/dev/null || { echo "unknown agent group '$w' — see: ncl groups list" >&2; exit 1; }; esac; done

Install the Dial CLI on the host

The host needs the dial CLI to sign in: dial auth login / dial auth verify-otp write the host auth file that the credential step below reads. Pinned to the same version the agent image gets, so host and sandbox agree:

command -v dial >/dev/null || npm install -g @getdial/cli@0.37.0

Sign in to Dial

Dial's CLI owns the account credential (an auth file it writes on sign-in).

Check the host sign-in

Is this host already signed in?

DIAL_USER_AGENT={{dial_ua}} dial doctor --json

Read the account

If it is, read which account — that account's key is what the chosen agents will use:

DIAL_USER_AGENT={{dial_ua}} dial doctor --json
This host is signed in to Dial as {{connected_email}}; the agents you chose will use that account. To give them a different account, run `dial auth login <email> --force` and `dial auth verify-otp --code <code>` on the host first, then run /add-dial-tool again.

Send the code

If it is not, verify an email with a one-time code. Collect the email:

What's your email? Dial sends a one-time code to verify it. By continuing you create a Dial account and agree to Dial's Terms of Service (https://getdial.ai/terms) and Privacy Policy (https://getdial.ai/privacy).

Send the code (--force re-sends even if a prior code is pending):

DIAL_USER_AGENT={{dial_ua}} dial auth login {{owner_email}} --force

Verify the code

Collect the code:

Enter the 6-digit code from your email

Verify it. Do not pass --agent nanoclaw here: this skill owns the container dial-cli skill, and --agent would drop a second, unmanaged copy next to it:

DIAL_USER_AGENT={{dial_ua}} dial auth verify-otp --code {{otp}}

Put the CLI and its skill in the agent image

The agent's global Node CLIs install from container/cli-tools.json, not from hand-edited Dockerfile layers. Add the pinned Dial CLI — idempotent on name, so a re-run is a no-op. @getdial/cli has no native postinstall, so no onlyBuilt:

{ "name": "@getdial/cli", "version": "0.37.0" }

The version (0.37.0) is the canonical pin — this document is the source of truth; the host install above uses the same one.

Mount the sandbox-aware dial-cli skill so the agent knows the CLI runs keyless in there and never asks for credentials. container/skills/ is mounted read-only into every agent container (at /app/skills) — which is why the key, not the skill file, is what gets scoped per agent:

container-skills/dial-cli/SKILL.md -> container/skills/dial-cli/SKILL.md

Rebuild the image so the CLI lands. On an install that fetches a published image this adds Dial as a layer on top of it; on one that builds its own it rebuilds:

./container/build.sh

Register the credential with OneCLI

Read the API key from the host auth file — the single source of truth, written by dial auth login / dial auth verify-otp — and put it in the OneCLI vault for api.getdial.ai. Always replace: the vault is keyed by name, so an existing "Dial API" secret is not necessarily this account's (re-onboarding, switching accounts, or rotating the key all leave a secret whose value points at the previous account, and a sandboxed agent then lists that account's numbers). A stale secret is deleted and a fresh one created rather than updated in place: onecli secrets update accepts a new value only on the command line, and the key must never sit on one. It travels through a 0600 temp file that is removed right after (--file), so it is never on argv or in a captured variable. Selective-mode agents pick the new id up in the merge step below:

T=$(mktemp) && chmod 600 "$T" && jq -r '.apiKey // empty' "${XDG_DATA_HOME:-$HOME/.local/share}/dial/auth.v1.json" > "$T" 2>/dev/null; [ -s "$T" ] || { rm -f "$T"; echo "no Dial API key in the host auth file — sign in with dial auth login / verify-otp, then re-run" >&2; exit 1; }; S=$(onecli secrets list | jq -r 'first(.data[] | select(.name | test("(?i)dial"))) | .id // empty'); if [ -n "$S" ]; then onecli secrets delete --id "$S" >/dev/null || { rm -f "$T"; echo "could not remove the previous Dial secret $S" >&2; exit 1; }; fi; onecli secrets create --name "Dial API" --type generic --file "$T" --host-pattern api.getdial.ai --header-name Authorization --value-format "Bearer {value}" >/dev/null; rc=$?; rm -f "$T"; exit $rc

Scope it to the chosen agents

Create the OneCLI agents

NanoClaw gives every agent group its own OneCLI agent whose identifier is the group id, created on the group's first spawn. A group that has never spawned has no OneCLI agent yet, and a block rule needs one to attach to — so create the missing ones now, exactly as the runtime would (secret mode all, nothing else touched):

G=$(ncl groups list --json) || { echo "could not list agent groups — is the NanoClaw host running?" >&2; exit 1; }; AG=$(onecli agents list) || { echo "could not list OneCLI agents" >&2; exit 1; }; printf '%s' "$G" | jq -r '.data[] | "\(.id)\t\(.name)"' | while IFS="$(printf '\t')" read -r gid gname; do printf '%s' "$AG" | jq -e --arg g "$gid" '.data[] | select(.identifier==$g)' >/dev/null || onecli agents create --name "$gname" --identifier "$gid" >/dev/null || { echo "could not create an OneCLI agent for $gname ($gid)" >&2; exit 1; }; done

Set the block rules

The one switch is a per-agent block rule on api.getdial.ai, named Dial: blocked for <group> so only this skill's rules are ever read or written (an operator's own rules on the host are left alone). A chosen agent has its rule removed; every other agent has one present and enabled. A 403 blocked_by_policy in a container means "not chosen", not "broken":

A=$(printf '%s' '{{dial_agents}}' | tr -d ' '); G=$(ncl groups list --json) || { echo "could not list agent groups — is the NanoClaw host running?" >&2; exit 1; }; case ",$A," in *,all,*) A=$(printf '%s' "$G" | jq -r '[.data[].id] | join(",")');; esac; AG=$(onecli agents list) || { echo "could not list OneCLI agents" >&2; exit 1; }; RL=$(onecli rules list) || { echo "could not list OneCLI rules" >&2; exit 1; }; printf '%s' "$G" | jq -r '.data[] | "\(.id)\t\(.name)"' | while IFS="$(printf '\t')" read -r gid gname; do aid=$(printf '%s' "$AG" | jq -r --arg g "$gid" 'first(.data[] | select(.identifier==$g)) | .id // empty'); [ -n "$aid" ] || { echo "no OneCLI agent for $gname ($gid)" >&2; exit 1; }; rid=$(printf '%s' "$RL" | jq -r --arg a "$aid" 'first(.data[] | select(.hostPattern=="api.getdial.ai" and .action=="block" and .agentId==$a and (.name | startswith("Dial: blocked for ")) and ((.pathPattern // "")=="") and ((.method // "")==""))) | .id // empty'); case ",$A," in *,"$gid",*) if [ -n "$rid" ]; then onecli rules delete --id "$rid" >/dev/null || { echo "could not remove the Dial block for $gname ($gid)" >&2; exit 1; }; fi; echo "allowed: $gname ($gid)";; *) if [ -z "$rid" ]; then onecli rules create --name "Dial: blocked for $gname" --host-pattern api.getdial.ai --action block --agent-id "$aid" --enabled >/dev/null || { echo "could not create the Dial block for $gname ($gid)" >&2; exit 1; }; else onecli rules update --id "$rid" --enabled true >/dev/null || { echo "could not re-enable the Dial block for $gname ($gid)" >&2; exit 1; }; fi; echo "blocked: $gname ($gid)";; esac; done

Merge secrets for selective agents

Secret lists are left alone, with one exception. An agent in selective mode only gets the secrets on its list, so a chosen selective agent has the Dial secret merged into it. onecli agents set-secrets switches an agent to selective mode, so it is never called on an all-mode agent — that would silently cut the agent off from every credential not on its list. Blocked agents keep their lists untouched in either mode; the rule alone blocks:

A=$(printf '%s' '{{dial_agents}}' | tr -d ' '); case ",$A," in *,all,*) A=$(ncl groups list --json | jq -r '[.data[].id] | join(",")');; esac; S=$(onecli secrets list | jq -r 'first(.data[] | select(.name | test("(?i)dial"))) | .id // empty'); [ -n "$S" ] || { echo "no Dial secret in the OneCLI vault — the credential step above did not complete" >&2; exit 1; }; onecli agents list | jq -r '.data[] | select(.secretMode=="selective") | "\(.id)\t\(.identifier)"' | while IFS="$(printf '\t')" read -r aid gid; do case ",$A," in *,"$gid",*) onecli agents set-secrets --id "$aid" --secret-ids "$(onecli agents secrets --id "$aid" | jq -r --arg s "$S" '[.data[], $s] | unique | join(",")')" >/dev/null || { echo "could not add the Dial secret to $gid" >&2; exit 1; }; echo "Dial secret added to the list of $gid";; esac; done

Hand the tool to running agents

container/skills/ is mounted read-only into every agent container, and each group's .claude-shared/skills/ holds symlinks into that mount that are synced when the container spawns — so nothing is copied per session. A running agent keeps its old image until it respawns, so restart every group; without a --message each one comes back on its next message, on the new image, with the CLI on PATH and the skill in place. This is a restart effect, so it does not fire after an earlier step bounced — agents keep the image they have until the gap above is fixed and the skill is re-applied:

ncl groups list --json | jq -r '.data[].id' | while read -r gid; do ncl groups restart --id "$gid" >/dev/null || { echo "could not restart $gid" >&2; exit 1; }; done

Done

The chosen agents can now use Dial from inside their containers; the others are blocked at the gateway. Auth is injected by OneCLI; a 403 blocked_by_policy means the agent was not chosen (run /add-dial-tool again to change that); a 401 means the Dial secret needs (re)connecting — not a login. Verify from a chat with a chosen agent: "run dial doctor" or "text +1… hi".

To uninstall: see REMOVE.md. To wire Dial as a messaging channel too, run /add-dial.

Troubleshooting

command -v onecli fails. OneCLI is not installed or not on PATH. Run /init-onecli, then re-run this skill.

ncl can't reach the host. The agent list and the scoping steps talk to the running NanoClaw service. Start it (pnpm run dev, or restart the service) and re-run.

unknown agent group. An id in your answer is not in ncl groups list. Copy the ag-… id exactly; names are not accepted.

no Dial API key in the host auth file. The sign-in did not complete. Run dial auth login <email> --force, then dial auth verify-otp --code <code>, and re-run.

A chosen agent gets 401. The vault secret is stale (a different account's key, or a rotated one). Re-run this skill — it always rewrites the secret with the key the host is signed in with.

An agent you left out can still use Dial. It was created after the last run (a new OneCLI agent starts in all mode with no rule). Re-run this skill; it only touches the per-agent rules.

dial: command not found inside a container. The image predates the manifest entry. Run ./container/build.sh, then ncl groups restart --id <group-id> so the agent respawns on it.

Files (nanoclaw)
  • container-skills
    • dial-cli
      • SKILL.md 8 KB
        ---
        name: dial-cli
        description: Reference for the `dial` CLI — gives you a real phone number to send SMS, place AI voice calls, and receive inbound texts/codes via the Dial platform (getdial.ai). Use when the user mentions phones, calls, texts, SMS, voice, OTP, 2FA, or verification codes; when they ask to text, call, ring, or wait for a code from someone; before running any `dial …` command for the first time in a session; or when investigating what the Dial platform can do. Load this skill before invoking the CLI — `dial --help` alone will not surface the workflows, the `--json` conventions, or the docs-lookup pattern needed to use Dial correctly.
        ---
        
        # Dial CLI
        
        `dial` is the official CLI for [Dial](https://getdial.ai) — a Communication Stack for AI Agents. It wraps the Dial REST API so you can send SMS, place voice calls handled by an AI voice agent, manage numbers, and wait for inbound events — all without writing HTTP code.
        
        The first time the user asks you to "text someone," "call someone," "receive a code," or anything else phone-shaped, reach for `dial`.
        
        ## This is a sandbox
        
        You are running inside a pre-provisioned, ephemeral environment:
        
        - **The account, number, and credentials are already set up** — you have a working phone number ready to use. There is nothing to configure.
        - **You never handle credentials.** The API key is injected automatically at the network boundary; the CLI runs keyless here. Never ask the user for a key, and never try to read or write an auth file.
        - **Just use the workflows below** — send SMS, place calls, wait for inbound events. Inbound event delivery is managed for you; `dial --help` shows what's available here.
        
        If `dial` is somehow missing (`command -v dial` returns nothing), that is an **installation bug in this image**, not something for you to fix — report it to the operator ("the Dial CLI isn't installed in this container") rather than improvising an install.
        
        ## Orient yourself before each new verb
        
        This skill **does not enumerate every flag**. The CLI is the source of truth — when you encounter a verb you have not used in this session, run its `--help` first:
        
        ```bash
        dial --help                    # available commands
        dial <command> --help          # flags + usage for a specific command
        dial <command> <sub> --help    # subcommand-level help
        ```
        
        Examples worth running on first use: `dial doctor --help`, `dial message --help`, `dial call --help`, `dial call get --help`, `dial wait-for --help`.
        
        Every command supports `--json` for machine-readable output — prefer it when piping into `jq` or parsing the result programmatically.
        
        ## Searching for what the CLI / API can do
        
        For anything beyond what `--help` shows on the local CLI, the canonical reference is the published docs. Two endpoints make this fast:
        
        ### Capability search — `llms-full.txt`
        
        A single concatenated markdown file of the whole docs site. Grep it directly for the keyword you care about:
        
        ```bash
        curl -fsSL https://docs.getdial.ai/llms-full.txt | grep -i -B2 -A8 'whatsapp'
        curl -fsSL https://docs.getdial.ai/llms-full.txt | grep -i -B1 -A5 'language'
        ```
        
        Use this when you want to know *if* Dial supports something, or *which command / endpoint* covers it — without reading the whole site.
        
        ### Deep dive — `sitemap.xml` + per-page `.md`
        
        When you need to read a page in detail, use the sitemap to discover URLs, then fetch the **`.md` companion** of any page — same content as the HTML page but plain markdown, faster to scan.
        
        ```bash
        # 1. Discover available pages
        curl -fsSL https://docs.getdial.ai/sitemap.xml | grep -oE 'https://docs\.getdial\.ai/[^<]+'
        
        # 2. For any page like https://docs.getdial.ai/documentation/get-started/introduction
        #    fetch the .md companion:
        curl -fsSL https://docs.getdial.ai/documentation/get-started/introduction.md
        ```
        
        The rule is **one-to-one**: every page at `https://docs.getdial.ai/<path>` has a markdown twin at `https://docs.getdial.ai/<path>.md`.
        
        ## Workflow shapes worth knowing
        
        These are the verbs you will most often compose. Read the relevant `.md` page for the full story; the one-liners below are signposts.
        
        - **Send an SMS** — `dial message --to +14155550123 --body "..."` ([send-an-sms.md](https://docs.getdial.ai/documentation/capabilities/send-an-sms.md))
        - **Show a typing indicator while composing** — `dial typing start --to-number +14155550123`; sending a message clears it natively, so start again between messages, and `dial typing stop --to-number +14155550123` if you end up not sending. iMessage numbers display it; SMS numbers ignore it, so it's always safe to call ([commands.md](https://docs.getdial.ai/documentation/reference/commands.md))
        - **Place a voice call** — `dial call --to +14155550123 --outbound-instruction "..."` then `dial call get <id>` once it ends. Add `--voice-gender male|female` to choose the agent's voice (default: female) ([place-a-voice-call.md](https://docs.getdial.ai/documentation/capabilities/place-a-voice-call.md))
        - **Buy an additional number** — `dial number purchase --inbound-instruction "..." --explicit-programmatic-consent "<attestation>"`. `--explicit-programmatic-consent` is **required**: a short attestation that the account holder consented to provisioning programmatically. Add `--include-imessage` for an [iMessage number](https://docs.getdial.ai/documentation/capabilities/send-an-imessage.md) (pay-as-you-go only; provisioned asynchronously — poll `dial number list` until ready) ([manage-phone-numbers.md](https://docs.getdial.ai/documentation/capabilities/manage-phone-numbers.md))
        - **Set a number's inbound behavior or nickname** — `dial number set +14155550123 --inbound-instruction "..."` and/or `--inbound-language es-ES` and/or `--nickname "Support line"` (at least one flag; `--nickname ""` / `--inbound-language ""` clear). The inbound instruction is the system prompt the AI uses on calls *into* that number. The inbound language pins inbound calls to one language — unset, the AI detects the caller's language from their country prefix (alongside en-US). The nickname is a human-readable label for telling numbers apart ([manage-phone-numbers.md](https://docs.getdial.ai/documentation/capabilities/manage-phone-numbers.md))
        - **Receive a verification code (2FA)** — `dial wait-for message.received -f channel=sms` and parse the body ([receive-inbound-sms.md](https://docs.getdial.ai/documentation/capabilities/receive-inbound-sms.md))
        - **React to a call ending** — `dial wait-for call.ended -f callId=<id>`. Fires however the call ends — completed, failed, **or cancelled** — carrying the terminal `status` and a `canceled` flag, so the wait always resolves ([stream-account-events.md](https://docs.getdial.ai/documentation/capabilities/stream-account-events.md))
        
        `dial wait-for` long-polls the Dial API directly, so it works here without any background daemon.
        
        ## Conventions
        
        - `--json` everywhere for parseable output.
        - **Always pass `--from-number` with this install's wired line** (stated at the end of this file if the channel is set up). This sandbox has no saved default sender — `defaultNumberId` is null here — so an omitted selector fails outright. Never pick one from `dial number list` instead: on a multi-number account that list is ordered newest-first, which is unrelated to the line this install is wired to, so a number chosen from it reaches nobody and replies to it are dropped. If no line is stated below and the user hasn't named one, ask rather than guess.
        - Phone numbers are E.164 (`+14155550123`). Reject anything else before calling Dial.
        - Writes (`message`, `call`, `number purchase`) are **not idempotent** — on an ambiguous failure, list first to check before retrying.
        
        ## When a call fails auth (401 / 403)
        
        You run keyless — the gateway injects the credential, so neither error is something you can fix from here. A `403 blocked_by_policy` means the operator chose not to give **this** agent Dial access: say so plainly and stop. A `401` means the operator's Dial credential is missing or invalid in the vault: tell the operator the Dial credential needs (re)connecting. In both cases stop and wait rather than retrying in a loop.
        
  • apply-fixtures.json 1.6 KB
    {
      "notes": "Conformance fixtures for scripts/skill-conformance.test.ts — shaped fake values only, never real credentials. Two scenarios cover the sign-in branches: (1) host already signed in (connected_email read from `dial doctor`), (2) not signed in → email + OTP. The `package.json` stub answers dial_ua; the `(.data|length)==0` stub (the agent-list capture's jq) answers agent_groups; `dial doctor` answers signed_in/connected_email. The scoping wires and the credential upsert run against the stubbed exec and capture nothing.",
      "scenarios": [
        {
          "name": "signed-in-pick-one",
          "inputs": {
            "dial_agents": "ag-11111111-1111-1111-1111-111111111111"
          },
          "exec": [
            {
              "match": "package.json",
              "stdout": "nanoclaw/2.2.0"
            },
            {
              "match": "(.data|length)==0",
              "stdout": "ag-11111111-1111-1111-1111-111111111111 (Sales), ag-22222222-2222-2222-2222-222222222222 (Support)"
            },
            {
              "match": "dial doctor",
              "stdout": "{\"auth\":{\"signedIn\":true,\"email\":\"operator@example.com\"}}"
            }
          ]
        },
        {
          "name": "fresh-signup-all",
          "inputs": {
            "dial_agents": "all",
            "owner_email": "operator@example.com",
            "otp": "123456"
          },
          "exec": [
            {
              "match": "package.json",
              "stdout": "nanoclaw/2.2.0"
            },
            {
              "match": "(.data|length)==0",
              "stdout": "ag-11111111-1111-1111-1111-111111111111 (Sales)"
            },
            {
              "match": "dial doctor",
              "stdout": "{\"auth\":{\"signedIn\":false}}"
            }
          ]
        }
      ]
    }
    
  • REMOVE.md 2 KB
    # Remove Dial Tool
    
    Reverses `/add-dial-tool`. Every step is idempotent — safe to re-run, and safe
    when only partially installed (skip any step whose target is already absent).
    Removes the **tool** only — it does not touch the Dial **channel** (`/add-dial`).
    
    ## 1. Remove the CLI from the agent image manifest
    
    Delete the `@getdial/cli` entry from `container/cli-tools.json`, keeping the
    top-level array valid:
    
    ```bash
    tmp=$(mktemp) && jq 'map(select(.name != "@getdial/cli"))' container/cli-tools.json > "$tmp" && mv "$tmp" container/cli-tools.json
    ```
    
    ## 2. Remove the container skill
    
    `container/skills/` is a read-only mount; the per-group `.claude-shared/skills/`
    symlink to it is pruned automatically on the next spawn:
    
    ```bash
    rm -rf container/skills/dial-cli
    ```
    
    ## 3. Remove the OneCLI credential and the per-agent block rules
    
    Deleting the secret is what revokes access for every agent. Per-agent secret
    lists are not edited (`set-secrets` would switch an `all`-mode agent to
    `selective` and cut it off from its other secrets). The block rules this skill
    created are name-prefixed, so only those go — an operator's own rules on
    `api.getdial.ai` stay:
    
    ```bash
    for id in $(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)dial")) | .id'); do onecli secrets delete --id "$id"; done
    for id in $(onecli rules list | jq -r '.data[] | select(.hostPattern=="api.getdial.ai" and .action=="block" and (.name | startswith("Dial: blocked for "))) | .id'); do onecli rules delete --id "$id"; done
    ```
    
    ## 4. Rebuild and restart the agents
    
    Rebuild the image so it matches the manifest, then restart every group so the
    agents respawn without the CLI (each comes back on its next message):
    
    ```bash
    ./container/build.sh
    ncl groups list --json | jq -r '.data[].id' | while read -r gid; do ncl groups restart --id "$gid"; done
    ```
    
    The Dial account, its numbers, and the host `dial` CLI are managed by Dial, not
    NanoClaw — `npm uninstall -g @getdial/cli` on the host if you no longer want it.
    
  • SKILL.md 15.6 KB
    ---
    name: add-dial-tool
    description: Give chosen NanoClaw agents a real phone number as a container tool — the `dial` CLI baked into the agent image plus OneCLI credential injection for api.getdial.ai, scoped per agent, so the agents you pick can send SMS, place AI voice calls, and receive verification codes from inside the sandbox. Independent of the Dial channel; idempotent; re-run to change which agents may use it. Use when the user wants agents to text, call, or run `dial …` from a chat, without wiring Dial as a messaging channel.
    ---
    
    # Add Dial Tool
    
    Installs Dial as a **container tool**: the `dial` CLI on the agent's `PATH`, the
    `dial-cli` skill so the agent knows how to drive it, and an OneCLI credential so
    in-container calls are injected keyless. Independent of the Dial **channel**
    (`/add-dial`) — install this alone. Idempotent: re-run it to change which agents
    may use Dial.
    
    **This tool spends money and reaches real people.** An agent with Dial access can
    text and call any number and buy more numbers, billed to the Dial account. The
    CLI and the skill file land in every agent's container, but the **key** is
    injected per agent by OneCLI, so the operator chooses which agents get it. Every
    other agent gets an OneCLI block rule and sees `403 blocked_by_policy` if it
    tries.
    
    Run this from the NanoClaw repo on the host (not from a chat with an agent — the
    container can't install itself). The mechanical steps carry `nc:` directive
    fences: an agent reads the prose and applies them, and a parser can apply them
    deterministically from the same document. Every directive is idempotent, so the
    whole skill is safe to re-run; anything a parser can't apply falls back to the
    prose beside it.
    
    ## Pre-flight
    
    OneCLI is required for credential injection — without it there is no way to hand
    the key to a container without putting it in an env var. This must succeed before
    anything else runs:
    
    ```nc:run effect:check
    command -v onecli >/dev/null
    ```
    
    If it fails, tell the user to run `/init-onecli` first, then retry. Stop here.
    
    Calls this setup makes to Dial identify the install. The `dial` CLI prepends
    `DIAL_USER_AGENT` to its own token, so the account's requests stay attributable
    to this NanoClaw install in Dial's server-side logs. Resolve the token once
    (`nanoclaw/<version>`; an unreadable `package.json` degrades to
    `nanoclaw/unknown` rather than blocking the install):
    
    ```nc:run capture:dial_ua validate:^nanoclaw/\S+$ effect:fetch
    node -p "'nanoclaw/'+(require('./package.json').version||'unknown')" 2>/dev/null || echo nanoclaw/unknown
    ```
    
    Prefix every `dial` command below with `DIAL_USER_AGENT={{dial_ua}}`.
    
    ## Choose which agents may use Dial
    
    List the agent groups (the NanoClaw service must be running — `ncl` talks to it
    over its socket):
    
    ```nc:run capture:agent_groups effect:fetch
    ncl groups list --json | jq -r 'if (.data|length)==0 then "no agent groups yet" else [.data[] | "\(.id) (\(.name))"] | join(", ") end'
    ```
    
    Ask the operator which of them may use Dial. Say plainly what they are granting,
    and ask even when there is a single agent:
    
    ```nc:operator
    Agents on this install: {{agent_groups}}. Giving an agent Dial lets it text and call any number and buy numbers, billed to your Dial account. Agents you leave out are blocked at the gateway (reversible by running /add-dial-tool again). Agents created after this run have Dial until the next run.
    ```
    ```nc:prompt dial_agents validate:^(all|none|ag-[A-Za-z0-9-]+(,ag-[A-Za-z0-9-]+)*)$ normalize:trim
    Which agents may use Dial? Enter agent ids separated by commas with no spaces (the `ag-…` column), `all` for every agent, or `none` to install the tool with every agent blocked for now.
    ```
    
    `all` and `none` cannot be mixed with ids, and an empty answer is never
    "everyone". A typo must not silently open or close anything, so every id named
    must be a real agent group:
    
    ```nc:run effect:check
    for w in $(printf '%s' '{{dial_agents}}' | tr ',' ' '); do case "$w" in all|none) ;; *) ncl groups list --json | jq -e --arg id "$w" '.data[] | select(.id==$id)' >/dev/null || { echo "unknown agent group '$w' — see: ncl groups list" >&2; exit 1; }; esac; done
    ```
    
    ## Install the Dial CLI on the host
    
    The host needs the `dial` CLI to sign in: `dial auth login` / `dial auth
    verify-otp` write the host auth file that the credential step below reads. Pinned
    to the same version the agent image gets, so host and sandbox agree:
    
    ```nc:run effect:external
    command -v dial >/dev/null || npm install -g @getdial/cli@0.37.0
    ```
    
    ## Sign in to Dial
    
    Dial's CLI owns the account credential (an auth file it writes on sign-in).
    
    ### Check the host sign-in
    
    Is this host already signed in?
    
    ```nc:run capture:signed_in=.auth.signedIn validate:^(true|false)$ effect:fetch
    DIAL_USER_AGENT={{dial_ua}} dial doctor --json
    ```
    
    ### Read the account
    
    If it **is**, read which account — that account's key is what the chosen agents
    will use:
    
    ```nc:run capture:connected_email=.auth.email when:signed_in=true effect:fetch
    DIAL_USER_AGENT={{dial_ua}} dial doctor --json
    ```
    ```nc:operator when:signed_in=true
    This host is signed in to Dial as {{connected_email}}; the agents you chose will use that account. To give them a different account, run `dial auth login <email> --force` and `dial auth verify-otp --code <code>` on the host first, then run /add-dial-tool again.
    ```
    
    ### Send the code
    
    If it is **not**, verify an email with a one-time code. Collect the email:
    
    ```nc:prompt owner_email validate:^[^@\s]+@[^@\s]+\.[^@\s]+$ when:signed_in=false
    What's your email? Dial sends a one-time code to verify it. By continuing you create a Dial account and agree to Dial's Terms of Service (https://getdial.ai/terms) and Privacy Policy (https://getdial.ai/privacy).
    ```
    
    Send the code (`--force` re-sends even if a prior code is pending):
    
    ```nc:run effect:external when:signed_in=false
    DIAL_USER_AGENT={{dial_ua}} dial auth login {{owner_email}} --force
    ```
    
    ### Verify the code
    
    Collect the code:
    
    ```nc:prompt otp validate:^\d{6}$ when:signed_in=false
    Enter the 6-digit code from your email
    ```
    
    Verify it. Do **not** pass `--agent nanoclaw` here: this skill owns the container
    `dial-cli` skill, and `--agent` would drop a second, unmanaged copy next to it:
    
    ```nc:run effect:external when:signed_in=false
    DIAL_USER_AGENT={{dial_ua}} dial auth verify-otp --code {{otp}}
    ```
    
    ## Put the CLI and its skill in the agent image
    
    The agent's global Node CLIs install from `container/cli-tools.json`, not from
    hand-edited Dockerfile layers. Add the pinned Dial CLI — idempotent on `name`, so
    a re-run is a no-op. `@getdial/cli` has no native postinstall, so no `onlyBuilt`:
    
    ```nc:json-merge into:container/cli-tools.json key:name
    { "name": "@getdial/cli", "version": "0.37.0" }
    ```
    
    The version (`0.37.0`) is the canonical pin — this document is the source of
    truth; the host install above uses the same one.
    
    Mount the sandbox-aware `dial-cli` skill so the agent knows the CLI runs keyless
    in there and never asks for credentials. `container/skills/` is mounted read-only
    into every agent container (at `/app/skills`) — which is why the key, not the
    skill file, is what gets scoped per agent:
    
    ```nc:copy
    container-skills/dial-cli/SKILL.md -> container/skills/dial-cli/SKILL.md
    ```
    
    Rebuild the image so the CLI lands. On an install that fetches a published image
    this adds Dial as a layer on top of it; on one that builds its own it rebuilds:
    
    ```nc:run effect:build
    ./container/build.sh
    ```
    
    ## Register the credential with OneCLI
    
    Read the API key from the host auth file — the single source of truth, written
    by `dial auth login` / `dial auth verify-otp` — and put it in the OneCLI vault
    for `api.getdial.ai`. Always **replace**: the vault is keyed by name, so an
    existing "Dial API" secret is not necessarily this account's (re-onboarding,
    switching accounts, or rotating the key all leave a secret whose value points at
    the previous account, and a sandboxed agent then lists *that* account's numbers).
    A stale secret is deleted and a fresh one created rather than updated in place:
    `onecli secrets update` accepts a new value only on the command line, and the key
    must never sit on one. It travels through a `0600` temp file that is removed right
    after (`--file`), so it is never on argv or in a captured variable. Selective-mode
    agents pick the new id up in the merge step below:
    
    ```nc:run effect:external
    T=$(mktemp) && chmod 600 "$T" && jq -r '.apiKey // empty' "${XDG_DATA_HOME:-$HOME/.local/share}/dial/auth.v1.json" > "$T" 2>/dev/null; [ -s "$T" ] || { rm -f "$T"; echo "no Dial API key in the host auth file — sign in with dial auth login / verify-otp, then re-run" >&2; exit 1; }; S=$(onecli secrets list | jq -r 'first(.data[] | select(.name | test("(?i)dial"))) | .id // empty'); if [ -n "$S" ]; then onecli secrets delete --id "$S" >/dev/null || { rm -f "$T"; echo "could not remove the previous Dial secret $S" >&2; exit 1; }; fi; onecli secrets create --name "Dial API" --type generic --file "$T" --host-pattern api.getdial.ai --header-name Authorization --value-format "Bearer {value}" >/dev/null; rc=$?; rm -f "$T"; exit $rc
    ```
    
    ## Scope it to the chosen agents
    
    ### Create the OneCLI agents
    
    NanoClaw gives every agent group its own OneCLI agent whose `identifier` is the
    group id, created on the group's first spawn. A group that has never spawned has
    no OneCLI agent yet, and a block rule needs one to attach to — so create the
    missing ones now, exactly as the runtime would (secret mode `all`, nothing else
    touched):
    
    ```nc:run effect:wire
    G=$(ncl groups list --json) || { echo "could not list agent groups — is the NanoClaw host running?" >&2; exit 1; }; AG=$(onecli agents list) || { echo "could not list OneCLI agents" >&2; exit 1; }; printf '%s' "$G" | jq -r '.data[] | "\(.id)\t\(.name)"' | while IFS="$(printf '\t')" read -r gid gname; do printf '%s' "$AG" | jq -e --arg g "$gid" '.data[] | select(.identifier==$g)' >/dev/null || onecli agents create --name "$gname" --identifier "$gid" >/dev/null || { echo "could not create an OneCLI agent for $gname ($gid)" >&2; exit 1; }; done
    ```
    
    ### Set the block rules
    
    The one switch is a per-agent **block rule** on `api.getdial.ai`, named
    `Dial: blocked for <group>` so only this skill's rules are ever read or written
    (an operator's own rules on the host are left alone). A chosen agent has its
    rule removed; every other agent has one present and enabled. A `403
    blocked_by_policy` in a container means "not chosen", not "broken":
    
    ```nc:run effect:wire
    A=$(printf '%s' '{{dial_agents}}' | tr -d ' '); G=$(ncl groups list --json) || { echo "could not list agent groups — is the NanoClaw host running?" >&2; exit 1; }; case ",$A," in *,all,*) A=$(printf '%s' "$G" | jq -r '[.data[].id] | join(",")');; esac; AG=$(onecli agents list) || { echo "could not list OneCLI agents" >&2; exit 1; }; RL=$(onecli rules list) || { echo "could not list OneCLI rules" >&2; exit 1; }; printf '%s' "$G" | jq -r '.data[] | "\(.id)\t\(.name)"' | while IFS="$(printf '\t')" read -r gid gname; do aid=$(printf '%s' "$AG" | jq -r --arg g "$gid" 'first(.data[] | select(.identifier==$g)) | .id // empty'); [ -n "$aid" ] || { echo "no OneCLI agent for $gname ($gid)" >&2; exit 1; }; rid=$(printf '%s' "$RL" | jq -r --arg a "$aid" 'first(.data[] | select(.hostPattern=="api.getdial.ai" and .action=="block" and .agentId==$a and (.name | startswith("Dial: blocked for ")) and ((.pathPattern // "")=="") and ((.method // "")==""))) | .id // empty'); case ",$A," in *,"$gid",*) if [ -n "$rid" ]; then onecli rules delete --id "$rid" >/dev/null || { echo "could not remove the Dial block for $gname ($gid)" >&2; exit 1; }; fi; echo "allowed: $gname ($gid)";; *) if [ -z "$rid" ]; then onecli rules create --name "Dial: blocked for $gname" --host-pattern api.getdial.ai --action block --agent-id "$aid" --enabled >/dev/null || { echo "could not create the Dial block for $gname ($gid)" >&2; exit 1; }; else onecli rules update --id "$rid" --enabled true >/dev/null || { echo "could not re-enable the Dial block for $gname ($gid)" >&2; exit 1; }; fi; echo "blocked: $gname ($gid)";; esac; done
    ```
    
    ### Merge secrets for selective agents
    
    Secret lists are left alone, with one exception. An agent in `selective` mode only
    gets the secrets on its list, so a **chosen** selective agent has the Dial secret
    merged into it. `onecli agents set-secrets` switches an agent to selective mode,
    so it is never called on an `all`-mode agent — that would silently cut the agent
    off from every credential not on its list. Blocked agents keep their lists
    untouched in either mode; the rule alone blocks:
    
    ```nc:run effect:wire
    A=$(printf '%s' '{{dial_agents}}' | tr -d ' '); case ",$A," in *,all,*) A=$(ncl groups list --json | jq -r '[.data[].id] | join(",")');; esac; S=$(onecli secrets list | jq -r 'first(.data[] | select(.name | test("(?i)dial"))) | .id // empty'); [ -n "$S" ] || { echo "no Dial secret in the OneCLI vault — the credential step above did not complete" >&2; exit 1; }; onecli agents list | jq -r '.data[] | select(.secretMode=="selective") | "\(.id)\t\(.identifier)"' | while IFS="$(printf '\t')" read -r aid gid; do case ",$A," in *,"$gid",*) onecli agents set-secrets --id "$aid" --secret-ids "$(onecli agents secrets --id "$aid" | jq -r --arg s "$S" '[.data[], $s] | unique | join(",")')" >/dev/null || { echo "could not add the Dial secret to $gid" >&2; exit 1; }; echo "Dial secret added to the list of $gid";; esac; done
    ```
    
    ## Hand the tool to running agents
    
    `container/skills/` is mounted read-only into every agent container, and each
    group's `.claude-shared/skills/` holds symlinks into that mount that are synced
    when the container spawns — so nothing is copied per session. A running agent
    keeps its old image until it respawns, so restart every group; without a
    `--message` each one comes back on its next message, on the new image, with the
    CLI on `PATH` and the skill in place. This is a restart effect, so it does not
    fire after an earlier step bounced — agents keep the image they have until the
    gap above is fixed and the skill is re-applied:
    
    ```nc:run effect:restart
    ncl groups list --json | jq -r '.data[].id' | while read -r gid; do ncl groups restart --id "$gid" >/dev/null || { echo "could not restart $gid" >&2; exit 1; }; done
    ```
    
    ## Done
    
    The chosen agents can now use Dial from inside their containers; the others are
    blocked at the gateway. Auth is injected by OneCLI; a `403 blocked_by_policy`
    means the agent was not chosen (run `/add-dial-tool` again to change that); a
    `401` means the Dial secret needs (re)connecting — not a login. Verify from a
    chat with a chosen agent: "run dial doctor" or "text +1… hi".
    
    To uninstall: see [REMOVE.md](REMOVE.md). To wire Dial as a **messaging
    channel** too, run `/add-dial`.
    
    ## Troubleshooting
    
    **`command -v onecli` fails.** OneCLI is not installed or not on `PATH`. Run
    `/init-onecli`, then re-run this skill.
    
    **`ncl` can't reach the host.** The agent list and the scoping steps talk to the
    running NanoClaw service. Start it (`pnpm run dev`, or restart the service) and
    re-run.
    
    **`unknown agent group`.** An id in your answer is not in `ncl groups list`. Copy
    the `ag-…` id exactly; names are not accepted.
    
    **`no Dial API key in the host auth file`.** The sign-in did not complete. Run
    `dial auth login <email> --force`, then `dial auth verify-otp --code <code>`, and
    re-run.
    
    **A chosen agent gets `401`.** The vault secret is stale (a different account's
    key, or a rotated one). Re-run this skill — it always rewrites the secret with the
    key the host is signed in with.
    
    **An agent you left out can still use Dial.** It was created after the last run
    (a new OneCLI agent starts in `all` mode with no rule). Re-run this skill; it
    only touches the per-agent rules.
    
    **`dial: command not found` inside a container.** The image predates the manifest
    entry. Run `./container/build.sh`, then `ncl groups restart --id <group-id>` so the
    agent respawns on it.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related