Commands
Overview
Every dial command shares three conventions:
--json— every command accepts this flag to print a machine-readable result instead of human text. Use this from agents.- Exit codes —
0on success, non-zero on failure, so scripts and agents can branch on the result. Each command makes a single attempt and doesn’t retry.callaccepts--idempotency-keyto make re-runs safe; other writes likemessagearen’t idempotent, so re-running after a failure can duplicate the action — confirm before retrying. DIAL_API_URL— environment variable to target a non-default deployment.DIAL_USER_AGENT— optional environment variable identifying the tool or agent runtime driving the CLI. It’s prepended to the CLI’s ownUser-Agent, which is always still sent — it never replaces it. SettingDIAL_USER_AGENT="nanoclaw/1.1.1"producesnanoclaw/1.1.1 @getdial/cli/0.36.0. Use the conventionalname/versionform. Leave it unset and the header is unchanged.
Run dial --version to print the installed version.
Sandbox mode
When the CLI runs inside a pre-provisioned, ephemeral agent container — for example NanoClaw’s per-agent sandboxes — it enters sandbox mode and adapts automatically:
- Host-management commands are turned off —
auth,listen,local-target,mcp,update, anduninstallare hidden from--helpand refuse to run (the container is provisioned and managed from outside, so they’d only confuse the agent). Everything you actually act with —message,call,number,typing,wait-for,billing,doctor— works normally. - Requests are keyless — the CLI sends no
Authorizationheader; a transparent proxy at the network boundary injects your credential, so there’s no API key to onboard or store inside the container.
Detection is automatic: the CLI infers sandbox mode when there’s no user service supervisor (no launchd / systemd --user) and HTTPS_PROXY is set. Override it explicitly with DIAL_SANDBOX — 1/true forces it on, 0/false forces it off. To opt a machine out permanently, create an empty .not-sandbox file in the Dial data dir (~/.local/share/dial/.not-sandbox).
All commands
Commands
doctor
Report account state and what to do next. Useful as a first check after install or when something looks off.
auth login
Email a 6-digit sign-up code to the given address. The first step of both signing up and signing in.
Flags
auth verify-otp
Submit a one-time code. Without --number it verifies the email code from auth login; with --number it verifies the SMS code from auth register-number.
What happens next depends on the account:
- Signing in to an existing account — the email step finishes the job: your key is saved and you’re authenticated.
- Creating a new account — the email step confirms your email and prints the
auth register-numbercommand to run next. Your key is issued only after the phone code is verified.
Pass --agent to install the Dial skill into that agent’s config directory. Skills install on the email step in both cases, so an agent has the instructions it needs to finish signing up.
Omit --code on a machine that’s already signed in and the command skips verification entirely — it just installs the requested --agent skills. This is the no-op re-run the integration pages rely on. Omitting --code while not signed in exits non-zero and tells you to run dial auth login <email> first. Passing --number with no phone registration pending also exits non-zero, pointing you at auth register-number.
Flags
auth register-number
Text a 6-digit code to the phone number that will own the account. Required to create an account; never needed to sign in.
The number must be able to receive SMS and should be one you keep; a Dial number can’t be used to register a Dial account. Re-run it with the same number to resend the code, or with a different number to correct a mistake before verifying.
Follow it with dial auth verify-otp --number --code <code>.
number list
List the phone numbers on your account.
number purchase
Purchase an additional phone number. Only US numbers can be provisioned at this time.
Flags
number set
Update a number’s properties. Takes the number in E.164 (resolved to its id for you). Pass any subset of the flags below — at least one is required. A new inbound instruction takes effect on the next inbound call.
Flags
number whatsapp
Connect WhatsApp to an iMessage number you already hold — the second way into the same flow number purchase --whatsapp takes. Accepts a phone number ID, one of your numbers in E.164, or a nickname.
Asynchronous, and it takes hours: the number comes back with its whatsapp status at provisioning, moves to warming_up once registered, and becomes ready after about 6 hours of warm-up. Wait with a long timeout (dial wait-for number.status_changed -f status=ready -f phoneNumberId=pn_123) or poll number list sparingly before sending on the channel; that track advances independently of the number’s own setupStatus.
WhatsApp is in beta and enabled per account. Without access this answers 404 — the same as an unknown number, because to such an account the capability doesn’t exist. The CLI reports it as an access message rather than “not found”.
Flags
message
Send a message, optionally with media. At least one of --body and --media is required — a media-only message sends just the attachment.
Give exactly one destination: --to for a phone number, or --group for a group conversation. Passing both, or neither, is refused before any request is made. A group send needs no from-number — the group already belongs to one of your lines — and a from-number naming a different line is refused rather than silently overridden.
On an iMessage number, a single audio attachment is delivered as a native voice message (with --body, the text and the voice message arrive as two messages; recipients without iMessage/RCS get the audio as a link over SMS). Pass --force-audio-file to send audio as a regular file attachment instead. On standard numbers audio is always MMS media and the flag has no effect.
On an iMessage number, --poll <title> with two or more --option sends a native poll instead of text (one-to-one or into an iMessage group). It’s exclusive with --body, --media and --force-audio-file; see Send a poll for the limits and how votes arrive.
Flags
message list
List recent messages on your account, newest first (up to 100).
A bare message list returns the newest 100 messages across every conversation, so an older thread can be missing from it entirely. Pass --contact to read one person’s conversation as itself, or --search to find a message by its text — both are applied by the API over your whole history rather than to those 100 rows.
On a message sent to a group the recipient column names the group, because a group message has no to — the destination is the group, and which of your numbers the conversation is on is its phoneNumberId.
A poll prints its title followed by each option and its vote count (poll: Which day? — Mon (1) · Tue (0)); a vote on one of your polls prints vote: <option id> or unvote: <option id> with its replyToId naming the poll.
Flags
message reply
Reply or react to an existing message (get IDs from message list or a
message.received event). The reply goes out from the Dial number the target
message belongs to, to the other party of that message — there’s no --to or
--from-number-id. Exactly one of --body / --react is required.
On an iMessage number, --body is delivered as a native threaded reply and
--react as a native reaction (a Tapback); the target must be an inbound
message. When the recipient can only receive SMS — including everything sent
from standard numbers — an emoji reaction arrives as a regular text whose body
is the emoji, and reaction names are rejected. On standard numbers --body
sends a regular SMS and targets in both directions are accepted.
On a WhatsApp number both are native, in a one-to-one conversation or a
group: --react lands on the target
message itself (a reaction name maps to its emoji), and --body is a quoted
reply. Targets in both directions are accepted.
A --body reply on an iMessage number needs a target that arrived over
iMessage or RCS — those carry the message id a thread attaches to. A target
that came in over the SMS fallback (the other party has neither) is rejected:
send a new dial message instead, which reaches the same conversation.
Flags
contacts
Every number your lines have exchanged a message or a call with, newest activity first, with that contact’s message and call counts and a preview of the most recent exchange.
A contact is derived from your history, not stored. There is no address book to add anyone to: a number appears here the moment it first texts or calls one of your lines, or one of your lines first reaches it, and it can’t be created or deleted.
Counts span every line on the account. One person who has reached two of your numbers is one contact whose totals are added together — the contact is the person, not the pairing. Pass --number-id to see one line’s contacts instead: the list narrows to the numbers that line has talked to, and each contact’s counts and latest interaction cover that line only.
Group conversations are not contacts. A group message is addressed to the group rather than to a person, so it is counted under neither the group nor the participant who sent it. group list is where those live.
Read one contact’s conversation with message list --contact <e164>, and their calls with call list --contact <e164>.
By default the command follows the cursor until it has every contact. Pass --limit to take a single page instead; when more remain it prints the --starting-after value for the next one.
Flags
lookup
What a phone number can receive, so you can pick a channel before you spend a send. Works on any number in the world — it doesn’t have to be one of yours, and you don’t have to have messaged it before.
The answer describes the number you asked about, not your own line. It is a live check rather than a stored property: someone who changes device or turns the service off stops being reachable, so the same number can answer differently another day. Treat a yes as a strong signal for choosing a channel, not as a promise that the send will land.
A lookup that fails exits non-zero rather than reporting no, so a no always means “we asked, and the number isn’t reachable there” — never “we couldn’t find out”.
Flags
typing start
Show a typing indicator to the recipient, as if someone were composing a message from your number. iMessage numbers display it; standard (SMS) numbers have no typing concept, so the command succeeds and does nothing — safe to run unconditionally before a send.
Pass --group instead of --to-number to show it in a
group conversation, where every member
sees it.
The indicator is fire-and-forget and free. Delivering a message or reaction
from the number clears it natively on the recipient’s device — run
typing start again after a send if you’re still composing, and typing stop
when you stop composing without sending. (A stale indicator may also clear on
its own device-side.)
Flags
typing stop
Clear a typing indicator previously shown with typing start. Same flags and
same no-op behavior on standard numbers. Clear a group’s indicator with the
same --group you started it with.
Flags
group list
List the group conversations your lines are in. A group is a conversation that isn’t a phone number, so it carries an ID of its own — pass it as --group to message or message list.
Each row shows the group’s ID, its channel (whatsapp or imessage), and its name. Groups exist on WhatsApp lines and on iMessage numbers, and your line has to already be a member: Dial can send into a group your number was added to, but can’t create one, join one, or accept an invite link.
A group’s name may be blank in the output. It isn’t stored — participants rename groups, so it’s read live from the line holding the conversation, and a line that can’t answer in time yields no name rather than hiding the group. Run it again if you need the name.
There’s no join event: a group your line was just added to shows up here the next time you run it.
Flags
call
Place an outbound voice call.
Flags
call list
List recent calls on your account, newest first (up to 100).
Flags
call get
Fetch a single call by ID — status, duration, transcript, and the rest of the call record. Useful for inspecting a transcript after a call.ended event.
call stop
End a call that hasn’t finished yet (Stop a call). A Queued or Ringing call is cancelled before it connects and ends as canceled; an In-Progress call is hung up and ends as completed. Prints the call with status.cancelPending: true — the call reaches Terminated a moment later, so follow with dial wait-for call.ended --field callId=<call_id> when you need to know it has ended. Fails if the call has already ended.
billing
Show the account’s billing status: credit-wallet balance, the current subscription (if any), each number’s billing mode (PAYG or FIXED), recent usage, recent credit deposits, and the saved payment methods (card brand, last four, and which is the default). With --json, prints the full billing object.
dial billing is read-only. Managing billing — adding credits, subscribing, cancelling, switching interval, or adding/removing payment methods — is done in the dashboard (embedded Stripe Checkout and one-click subscription actions), not from the CLI.
wait-for
Block until a matching event arrives. Reads from the local listen service log when the daemon is running; otherwise falls back to the REST API automatically. A match prints the event and exits 0; a timeout exits non-zero.
Flags
listen install
Install the background event daemon (launchd on macOS, systemd user unit on Linux). See Listen service.
listen status
Report the listen daemon’s state and its last few events.
listen uninstall
Stop and remove the listen daemon.
Register a local fan-out target on the listen daemon. The daemon delivers every account event it receives to each registered target. Two variants:
url— POST the event JSON to a local HTTP endpoint. Best for integrations that already speak HTTP webhooks (Local URL target).cmd— spawn an executable per event, passing the event JSON as the final positional argument. Best for shell scripts and one-off automations (CLI command target).
Both variants require the listen daemon to be installed (dial listen install) — fan-out runs inside that daemon. No restart needed: the daemon picks up subscribe/unsubscribe changes on the fly.
local-target add url
Register a local HTTP endpoint. The daemon POSTs the event JSON to the URL as the request body.
The URL must be http:// or https:// and resolve to a local loopback host — 127.0.0.1, 0.0.0.0, localhost, ::1, or [::1]. Public URLs are rejected; this command is for processes running on the same machine as the listen daemon.
Flags
local-target add cmd
Register an executable. For each event the daemon spawns the program and passes the event JSON as the final positional argument — anything you put after the executable path is forwarded verbatim before that argument. The two forms above invoke, respectively:
Exit code 0 is treated as delivered. On a non-zero exit or timeout the daemon retries the invocation once, then gives up; nothing is queued or retried further. Stdout/stderr are captured into the listen log so you can see what the executable printed.
The path must be absolute or resolvable on the daemon’s PATH. The daemon runs each invocation in a clean environment except for PATH and HOME.
Flags
local-target remove
Unregister a target by the same identifier you registered (URL for url, path for cmd). The daemon stops fanning out to that target immediately — no restart needed.
local-target list
List the local targets currently registered for fan-out. Each row is tagged url or cmd so you can tell them apart.
mcp
Run a local Model Context Protocol server over stdio, exposing every operational command above as an MCP tool. Point a local MCP client at dial mcp as the server command and the agent can send messages, place calls, manage numbers, wait for events, and run the local setup verbs (auth, listen, local-target) as tools.
It reuses the API key saved by dial auth verify-otp (~/.local/share/dial/auth.v1.json) — no OAuth, no separate login. This is the local counterpart to the hosted Remote MCP server: same operational tools, plus the local-machine verbs the remote server can’t offer. See the MCP integration page for client setup and a Remote-vs-Local comparison.
This command runs in the foreground and speaks JSON-RPC on stdout; it has no --json flag.
update
Update the CLI to the latest published version — a foreground npm install -g @getdial/cli@latest — and report the previous and installed versions. Only global npm installs are updatable: npx runs already resolve the latest version on every invocation, and source checkouts update through git; both exit 2 with guidance.
You rarely need this: the CLI also updates itself automatically. At most once an hour, any command (except update and uninstall) spawns the same npm update fully detached in the background. The background check never touches a command’s output — progress and failures land in ~/.local/state/dial/cli.log. Set DIAL_NO_AUTO_UPDATE=1 to disable it.
uninstall
Remove everything Dial put on this machine, without prompting: stop and remove the listen daemon, delete the dial-cli skill from every agent it was installed into, and delete Dial’s config, data (including the saved API key), and state directories. Steps are best-effort — a failure in one doesn’t stop the rest. It finishes by printing the one step it can’t perform on itself:
Uninstall is local-only: your account and numbers are untouched, and signing back in is the same email flow (dial auth login + dial auth verify-otp). With --json, the result reports each step’s outcome plus an errors array; the exit code is 0 when every step succeeded, 2 otherwise.