Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.17.0

Adds approvals. Your agents can now ask for your approval before they act, such as sending an email or deploying. `activitysmith approval request` shows the request on your iPhone and waits for your answer. It exits with code 0 only when you approve, so the agent continues only after that. `approval wait`, `approval get`, and `approval cancel` work with a request you already sent.

## 1.16.0

Adds two commands so a coding agent can finish setup on its own. `activitysmith devices list` shows the iPhones and iPads paired to your account, and points to the App Store when none is paired. `activitysmith keys create` creates an API key for CI, servers, and scripts with the browser sign-in; the first time, it asks you to approve API key access in the browser. Add `--raw` to print only the key, so it can go straight into a secret store.
Expand Down
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,73 @@ Pass `0` to clear the badge.
activitysmith badge 0
```

## Approvals

![Approval Live Activity on an iPhone Lock Screen with Send and Deny buttons](https://cdn.activitysmith.com/features/ai-agent-approval-live-activity-phone.png)

Let your agents ask for your approval before they act, such as sending an email to customers or deploying to production. The request reaches your iPhone as a Live Activity or an actionable Push Notification, both with two buttons. The agent waits until you tap one and continues only when you approve.

```bash
activitysmith approval request "Send the pricing update email to 214 customers?" \
--approve-label "Send" \
--reject-label "Don't Send"
```

When you approve, the command prints `Approved.` and exits with code `0`. The agent, or a script, runs the action only after that:

```bash
activitysmith approval request "Deploy to production?" --approve-label "Deploy" && ./deploy.sh
```

Exit codes for `approval request` and `approval wait`:

- `0`: Approved. Continue with the action.
- `1`: Rejected, timed out after 30 minutes, or cancelled. Do not continue.
- `2`: The request failed, for example because no device is paired.
- `3`: Still pending after `--timeout`, or sent with `--no-wait`.

### Show the exact command

Add `--command` with the exact command that waits for approval. The request shows the command and the current directory:

```bash
activitysmith approval request "Run the database migration?" \
--approve-label "Migrate" \
--command "npm run db:migrate -- --env production"
```

Add `--details` for short context. Add `--scope` to show where the action applies, such as `environment:production`.

### Wait in steps

Some agents stop shell commands after a few minutes. Add `--timeout` to stop waiting early. The command then prints the approval ID and exits with code `3`. Run `approval wait` to keep waiting:

```bash
activitysmith approval request "Publish the release notes?" --timeout 100
activitysmith approval wait apr_Xk3pQ8mV2nR7tL9w --timeout 100
```

### Check or cancel a request

```bash
activitysmith approval get apr_Xk3pQ8mV2nR7tL9w
activitysmith approval cancel apr_Xk3pQ8mV2nR7tL9w
```

Cancel a request when the action is no longer needed. Its buttons stop working, and a Live Activity updates to show the request was cancelled.

### Change how the request looks

By default, the request is a Live Activity, with an actionable Push Notification as the fallback. Add `--delivery push_notification` to always send a Push Notification, or `--delivery live_activity` to always send a Live Activity. Use `--badge`, `--color`, and `--icon` to change the Live Activity:

```bash
activitysmith approval request "Refund order #4821 for $129?" \
--approve-label "Refund" \
--badge "Billing" \
--color orange \
--icon creditcard
```

## Metadata

Metadata adds extra information to Push Notification and Live Activity details in ActivitySmith. It does not appear in the notification or Live Activity on your device.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "activitysmith-cli",
"version": "1.16.0",
"version": "1.17.0",
"description": "Command-line interface for ActivitySmith. Send Push Notifications, start, update, and end Live Activities, and set App Icon Badge Counts from your terminal.",
"keywords": [
"activitysmith",
Expand Down
24 changes: 23 additions & 1 deletion skills/activitysmith/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: activitysmith
description: Send ActivitySmith Push Notifications, start, update, and end Live Activities, set App Icon Badge Counts, and include notification taps or actions that run a specific iPhone Shortcut from coding agents through the ActivitySmith CLI.
description: Send ActivitySmith Push Notifications, start, update, and end Live Activities, ask for approval on the user's iPhone before an action, set App Icon Badge Counts, and include notification taps or actions that run a specific iPhone Shortcut from coding agents through the ActivitySmith CLI.
---

# ActivitySmith
Expand All @@ -14,6 +14,8 @@ Use this skill when the user wants an iPhone/iPad signal from an agent:
- "keep a metric, status, or progress visible while you work"
- "show this metric on my Lock Screen"
- "tell me if you get blocked"
- "ask me before you send emails to customers"
- "get my approval before any purchase or deploy"

The CLI is the tool. This skill is the intent mapping and recipe layer.

Expand Down Expand Up @@ -49,6 +51,7 @@ If it prints `No paired devices.`, give the user the App Store link it prints. A
- Push Notification with actions: long-press should show up to 4 buttons.
- Live Activity: a real-time metric, operational state, or ongoing task should stay visible.
- Widget metric: one stable value should stay visible after the task ends.
- Approval: the user asked to approve a kind of action before you do it.
- Channels: only specific teammates/devices should receive the update.
- Tags: related Push Notifications and Live Activities should be grouped in history.

Expand Down Expand Up @@ -188,6 +191,24 @@ Use this when the agent needs human input.
-m "I hit a blocker and need your decision."
```

### Ask For Approval Before An Action

Use this when the user set a rule, such as "ask me before you send emails to customers" or "get my approval before any purchase". The rule applies every time. Ask right before each matching action:

```bash
activitysmith approval request "Send the pricing update email to 214 customers?" \
--approve-label "Send" \
--reject-label "Don't Send" \
--timeout 100
```

- Exit `0` (`Approved.`): do the action.
- Exit `1` (rejected, timed out, or cancelled): do not do the action. Tell the user.
- Exit `3` (`Still pending`): run `activitysmith approval wait <approval-id> --timeout 100`. Repeat until it exits `0` or `1`.
- Exit `2`: the request failed. Report the error and do not do the action.

Write a question the user can answer from the Lock Screen. Use a concrete approve label, such as Send, Deploy, or Buy. For a shell command, pass the exact command with `--command`. If the action is no longer needed, run `activitysmith approval cancel <approval-id>`.

### Update A Widget Metric

Use this when a stable metric should stay visible on the Lock Screen.
Expand Down Expand Up @@ -247,5 +268,6 @@ activitysmith badge 3 --channels "sales,customer-success"
- For long-running progress, start one Live Activity, update it, then end it.
- Add tags when the user wants related Push Notifications and Live Activities grouped in history.
- Capture the Activity ID when using start/update/end.
- Never do an action the user asked to approve without an `Approved.` result. One approval covers one action.
- Do not expose API keys, tokens, or secrets in titles, messages, action URLs, or webhook bodies.
- If a command fails, report the error and do not retry repeatedly without a change.
225 changes: 220 additions & 5 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,7 @@ const buildErrorDetails = async (error) => {
// Set when a command authenticates with the browser sign-in rather than an API key.
let usingSignIn = false;

const handleError = async (error, options) => {
const handleError = async (error, options, exitCode = 1) => {
const details = await buildErrorDetails(error);
// Disconnecting the CLI in the web app rejects its token at once, before it expires.
if (usingSignIn && details.status === 401) {
Expand Down Expand Up @@ -1256,7 +1256,7 @@ const handleError = async (error, options) => {
}
}

process.exit(1);
process.exit(exitCode);
};

// Returns the API key, or a current access token from the saved sign-in. The REST API accepts
Expand Down Expand Up @@ -1439,9 +1439,7 @@ metricsCommand
.description("Update a widget metric value")
.argument("<metric-key>", "Metric key")
.argument("<value>", "Metric value")
.option("--metadata <json>", "Metadata JSON object shown in ActivitySmith details")
.option("--metadata-file <path>", "Path to a Metadata JSON object file")
.action(async (metricKey, rawValue) => {
.action(async (metricKey, rawValue) => {
const globalOptions = program.opts();

try {
Expand Down Expand Up @@ -1837,6 +1835,223 @@ keysCommand
}
});

// Approvals. Agents ask the user for approval before an action the user wants to approve, such as
// sending an email or deploying, and go ahead only when this exits 0.
const APPROVAL_SCOPE_TYPES = ["working_directory", "repository", "project", "environment", "customer", "account", "resource"];
const APPROVAL_COLORS = ["lime", "green", "cyan", "blue", "purple", "magenta", "red", "orange", "yellow", "gray"];
// Each request waits at most this long on the server, then the CLI asks again.
const APPROVAL_POLL_SECONDS = 45;
const APPROVAL_EXIT = { approved: 0, not_approved: 1, error: 2, pending: 3 };

const parseApprovalScope = (value) => {
const separator = value.indexOf(":");
const type = separator > 0 ? value.slice(0, separator).trim() : "";
const scopeValue = separator > 0 ? value.slice(separator + 1).trim() : "";
if (!APPROVAL_SCOPE_TYPES.includes(type) || !scopeValue) {
throw new InvalidArgumentError(
`scope must be <type>:<value>, where type is one of ${APPROVAL_SCOPE_TYPES.join(", ")}`
);
}
return { type, value: scopeValue };
};

const parseApprovalColor = (value) => {
if (!APPROVAL_COLORS.includes(value)) {
throw new InvalidArgumentError(`color must be one of ${APPROVAL_COLORS.join(", ")}`);
}
return value;
};

const parsePositiveSeconds = (value) => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new InvalidArgumentError("timeout must be a whole number of seconds, 1 or more");
}
return parsed;
};

const APPROVAL_RESULT_LINES = {
approved: "Approved.",
rejected: "Rejected. Do not continue with the action.",
timed_out: "Timed out. No decision within 30 minutes. Do not continue with the action.",
cancelled: "Cancelled. Do not continue with the action.",
};

// Waits until the approval is decided, or until `timeoutSeconds` pass, then prints the result and exits.
const waitForApproval = async (approvalId, token, timeoutSeconds, globalOptions) => {
const deadline = timeoutSeconds ? Date.now() + timeoutSeconds * 1000 : Number.POSITIVE_INFINITY;
let result;
while (true) {
const remaining = Math.ceil((deadline - Date.now()) / 1000);
const wait = Math.max(1, Math.min(APPROVAL_POLL_SECONDS, remaining));
const startedAt = Date.now();
result = await apiRequest(`/approvals/${encodeURIComponent(approvalId)}?wait=${wait}`, { token });
if (result.approval?.status !== "pending" || Date.now() >= deadline) break;
// The server normally holds the request until it decides or the wait ends; never ask again in a tight loop.
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 1000 - (Date.now() - startedAt))));
}

const status = result.approval?.status;
if (status === "pending") {
outputResult(result, globalOptions, [
`Still pending. Approval ID: ${approvalId}`,
`Run activitysmith approval wait ${approvalId} to keep waiting.`,
]);
process.exit(APPROVAL_EXIT.pending);
}
outputResult(result, globalOptions, [APPROVAL_RESULT_LINES[status] ?? `Status: ${status}`]);
process.exit(status === "approved" ? APPROVAL_EXIT.approved : APPROVAL_EXIT.not_approved);
};

const approvalCommand = program
.command("approval")
.description("Let your agents ask for your approval on your iPhone before they act")
.addHelpText(
"after",
`
Exit codes for request and wait:
0 Approved. Continue with the action.
1 Rejected, timed out, or cancelled. Do not continue.
2 The request failed, for example no paired device.
3 Still pending after --timeout or with --no-wait. Run approval wait with the approval ID.`
);

approvalCommand
.command("request")
.description("Send an approval request with two buttons to the iPhone and wait for the decision")
.argument("<question>", 'The decision to make, such as "Send the pricing update email to 214 customers?"')
.option("--details <text>", "Short context that helps decide")
.option("--approve-label <label>", "Approve button title, a concrete verb such as Send or Deploy", "Approve")
.option("--reject-label <label>", "Reject button title, such as Cancel or Don't Send", "Reject")
.option("--command <command>", "The exact command waiting for approval. Do not shorten or rewrite it")
.option("--tool <name>", "Name of the operation waiting for approval (default: shell with --command)")
.option(
"--scope <type:value>",
`Where the action applies, such as environment:production. Types: ${APPROVAL_SCOPE_TYPES.join(", ")} (default: working_directory:<current directory> with --command)`,
parseApprovalScope
)
.addOption(
new Option("--delivery <surface>", "Live Activity, Push Notification, or auto: a Live Activity with a Push Notification fallback")
.choices(["auto", "live_activity", "push_notification"])
.default("auto")
)
.option("--color <color>", "Live Activity color (default: blue)", parseApprovalColor)
.option("--icon <symbol>", "SF Symbol for the Live Activity (default: sparkles)")
.option("--badge <title>", "Short Live Activity badge such as Email or Deploy (default: Agent)")
.option("--channels <slugs>", "Comma-separated channel slugs. Omit to ask on every paired device")
.option("--timeout <seconds>", "Stop waiting after this many seconds and exit 3 if still pending", parsePositiveSeconds)
.option("--no-wait", "Send the request, print the approval ID, and exit without waiting")
.action(async (question, options) => {
const globalOptions = program.opts();

try {
const token = await requireApiKey(globalOptions);
const tool = options.tool ?? (options.command ? "shell" : undefined);
if (options.command && !tool) throw new Error("Provide --tool with --command.");
const scope = options.scope ?? (options.command ? { type: "working_directory", value: process.cwd() } : undefined);
const appearance = {
...(options.color ? { color: options.color } : {}),
...(options.icon ? { icon: { symbol: options.icon } } : {}),
...(options.badge ? { badge: { title: options.badge } } : {}),
};
const channels = String(options.channels ?? "")
.split(",")
.map((slug) => slug.trim())
.filter(Boolean);

const result = await apiRequest("/approvals", {
token,
method: "POST",
body: {
question,
...(options.details ? { details: options.details } : {}),
...(tool ? { execution_context: { tool, ...(options.command ? { command: options.command } : {}) } } : {}),
...(scope ? { scope } : {}),
delivery: options.delivery,
approve_label: options.approveLabel,
reject_label: options.rejectLabel,
...(Object.keys(appearance).length > 0 ? { appearance } : {}),
...(channels.length > 0 ? { target_channel_slugs: channels } : {}),
},
});
const approvalId = result.approval?.id;
if (!approvalId) throw new Error("The API did not return an approval ID. Run the command again.");

if (options.wait === false) {
outputResult(result, globalOptions, [
`Approval requested. Approval ID: ${approvalId}`,
`Run activitysmith approval wait ${approvalId} to wait for the decision.`,
]);
process.exit(APPROVAL_EXIT.pending);
}
if (!globalOptions.json) {
console.error(`Approval requested. Waiting for the decision on the iPhone. Approval ID: ${approvalId}`);
}
await waitForApproval(approvalId, token, options.timeout, globalOptions);
} catch (error) {
await handleError(error, globalOptions, APPROVAL_EXIT.error);
}
});

approvalCommand
.command("wait")
.description("Wait for the decision on an approval request")
.argument("<approval-id>", "Approval ID from approval request")
.option("--timeout <seconds>", "Stop waiting after this many seconds and exit 3 if still pending", parsePositiveSeconds)
.action(async (approvalId, options) => {
const globalOptions = program.opts();

try {
const token = await requireApiKey(globalOptions);
await waitForApproval(approvalId, token, options.timeout, globalOptions);
} catch (error) {
await handleError(error, globalOptions, APPROVAL_EXIT.error);
}
});

approvalCommand
.command("get")
.description("Show the current state of an approval request without waiting")
.argument("<approval-id>", "Approval ID from approval request")
.action(async (approvalId) => {
const globalOptions = program.opts();

try {
const token = await requireApiKey(globalOptions);
const result = await apiRequest(`/approvals/${encodeURIComponent(approvalId)}`, { token });
const status = result.approval?.status;
outputResult(result, globalOptions, [
status === "pending" ? "Pending." : (APPROVAL_RESULT_LINES[status] ?? `Status: ${status}`),
`Approval ID: ${approvalId}`,
result.approval?.question ? `Question: ${result.approval.question}` : null,
]);
} catch (error) {
await handleError(error, globalOptions, APPROVAL_EXIT.error);
}
});

approvalCommand
.command("cancel")
.description("Withdraw a pending approval request that is no longer needed")
.argument("<approval-id>", "Approval ID from approval request")
.action(async (approvalId) => {
const globalOptions = program.opts();

try {
const token = await requireApiKey(globalOptions);
const result = await apiRequest(`/approvals/${encodeURIComponent(approvalId)}/cancel`, { token, method: "POST" });
const status = result.approval?.status;
outputResult(result, globalOptions, [
status === "cancelled"
? "Approval request cancelled. It can no longer be approved."
: `Not cancelled: the request is already ${String(status).replace("_", " ")}.`,
`Approval ID: ${approvalId}`,
]);
} catch (error) {
await handleError(error, globalOptions, APPROVAL_EXIT.error);
}
});

const authCommand = program
.command("auth")
.description("Sign in through the browser and manage the saved sign-in");
Expand Down
Loading
Loading