Guides
Approve an agent's work from Slack
Hold an agent's writes for a human, summarise each run in the account's Slack channel, and let a listed approver decide with one reaction.
An agent that acts for a customer account — reconciling an accounting system, filing documents in a file store — should not write anything a human has not seen. This guide builds the whole loop: one agent per account holding that account’s connections, a short-lived agent per job, a rule that holds every write, a webhook that turns a run’s held requests into one Slack message, and a reaction handler that decides them on the approver’s behalf. It assumes you have read Give an agent scoped access (agents, delegation and rules), Add human-in-the-loop approvals (holding and deciding requests) and Outbound webhooks (registering and verifying deliveries); it does not repeat them.
Structure: one agent per account, one agent per job
Section titled “Structure: one agent per account, one agent per job”Give each customer account its own long-lived client agent. It holds that account’s connections — the accounting system, the file store — delegated to it with narrowing rules: a folder limit, the operations it may use, the exact queries it may run. Nothing else in your deployment touches those connections.
For each job, your launcher creates a short-lived run agent that holds only what that job declares, delegated from the client agent with an expiry. A run agent that leaks cannot reach another account or outlive its job.
A run agent’s job is not over when its code stops: its writes are still waiting for a person. Deleting an agent cancels every approval it has pending (they expire with expiry_reason agent_revoked), and an approved write re-checks the agent and its delegation just before it runs. So delete the run agent only once nothing in its run is still pending, approved or executing — Clean up the run agent below does it from the webhook — and give its delegation an expiry longer than the job plus the approval window, so a write approved near the end of that window can still run.
Before the first run: the account’s approvers and connections
Section titled “Before the first run: the account’s approvers and connections”Three things must exist before the launcher can hand a run agent anything.
1. A rule naming the account’s approvers, set by an administrator. A delegation can only narrow what is already on its chain, and that includes who approves: a require_approval rule on a delegation may name only approvers that a require_approval rule already on the chain names (a subset of them is fine). A delegation naming anyone else is refused with delegation_rule_widens, whose detail_reason is approver_outside_chain. And a managed-secret connection has no owner to route an approval to, so a rule on one must always name an approver. So an organization or application administrator first creates the rule that names the account’s people, on the connection the agents will use, with the CLI (alter policy rules create, --secret for every grant of a managed secret or --grant for one grant) or on the secret’s Policies tab in the dashboard:
cat > approvers.json <<'EOF'{ "effect": "require_approval", "when": { "method": ["POST", "PUT", "PATCH", "DELETE"] }, "approval": { "approvers": ["ops-lead@example.com", "controller@example.com"], "expires_in_seconds": 3600, "channels": ["email"] }}EOFalter policy rules create --app <app-id-or-name> --secret accounting-production \ --type require_approval --name "Account 7421 approvers" --body @approvers.json"channels": ["email"] is required here, not decoration. When a second rule (the run agent’s delegation rule below, or the account owner’s own) also holds a write, the request becomes a group of gates, and a multi-gate request is refused unless every gate notifies by email. A rule with no email channel would make every such write fail when it is held.
2. The client agent’s connections, delegated by the account’s person. A run agent receives its connections by onward delegation from the client agent, and a grant can be delegated onward only when a user consented to it with onward sharing allowed. A grant an operator gave straight to an agent has no consenting user, so it can never be re-delegated (GrantNotDelegableError, no_consenting_user). The person who holds the account’s connection therefore delegates it to the client agent and allows sharing, either in the Wallet (Managed Secrets → the connection → Agents → Delegate to agent, with May share this access with other agents ticked) or through a Connect session that names the agent with delegable=True (delegable: true in TypeScript), which needs the application’s identity provider to sign the person in. The managed secret’s delegation policy must allow delegation, with a depth of at least two (person → client agent → run agent). Give an agent scoped access covers both paths.
3. Operations, for a custom integration. An operation_allowlist by family admits a request only when the request is classified as an operation of that family. A built-in provider is catalogued already. A custom integration (a managed secret with no provider template) admits nothing by family until it has operations (from an API spec URL, or authored on the secret’s Operations tab) and an organization administrator has trusted its family mapping; until then a delegation carrying a family allowlist on it is refused when it is made. Name operation ids in the allowlist instead if the mapping is not trusted.
import os
from alter_sdk import App, operation_allowlist_rule
app = App(api_key=os.environ["ALTER_LAUNCHER_KEY"]) # agents:write + grants:write
# Once per account: the client agent. The account's person then delegates# the account's connections to it, with onward sharing allowed (above).client = await app.agents.create( name="acct-7421-client", scopes={"managed_secret_slugs": ["accounting-production", "file-store-production"]}, tags={"account": "7421"},)
# Per job: a run agent that holds only what this job declares.run = await app.agents.create( name="acct-7421-run-2026-11-06-01", scopes={"managed_secret_slugs": ["accounting-production"]}, tags={"account": "7421", "run": "run_2026_11_06_01"},)holder = app.get_agent(client.id) # the launcher acting as the client agentheld = await holder.list_grants(tags={"account": "7421"})for grant in held.grants: await holder.delegate( grant.grant_id, run.id, # Longer than the job plus the approval window below (1 hour): an # approved write re-checks this delegation before it runs. ttl_seconds=4 * 3600, label="run_2026_11_06_01", rules=[ operation_allowlist_rule(families=["read", "write"]), { "rule_type": "require_approval", "rule_body": { "effect": "require_approval", "when": {"method": ["POST", "PUT", "PATCH", "DELETE"]}, "approval": { # Only approvers the administrator's rule names. "approvers": ["ops-lead@example.com", "controller@example.com"], "expires_in_seconds": 3600, "channels": ["email"], }, }, }, ], )# ... the job runs as the run agent (its own key is run.api_key, returned once) ...# Do not delete the run agent here: its writes are still waiting for approval.# Record run.id against the run; the webhook deletes it once the run settles.await save_run_agent("run_2026_11_06_01", run.id) # your storeimport { App, operationAllowlistRule } from "@alter-ai/alter-sdk";
const app = new App({ apiKey: process.env.ALTER_LAUNCHER_KEY! }); // agents:write + grants:write
// Once per account: the client agent. The account's person then delegates// the account's connections to it, with onward sharing allowed (above).const client = await app.agents.create({ name: "acct-7421-client", scopes: { managed_secret_slugs: ["accounting-production", "file-store-production"] }, tags: { account: "7421" },});
// Per job: a run agent that holds only what this job declares.const run = await app.agents.create({ name: "acct-7421-run-2026-11-06-01", scopes: { managed_secret_slugs: ["accounting-production"] }, tags: { account: "7421", run: "run_2026_11_06_01" },});const holder = app.getAgent(client.id); // the launcher acting as the client agentconst held = await holder.listGrants({ tags: { account: "7421" } });for (const grant of held.grants) { await holder.delegate(grant.grantId, run.id, { // Longer than the job plus the approval window below (1 hour): an // approved write re-checks this delegation before it runs. ttlSeconds: 4 * 3600, label: "run_2026_11_06_01", rules: [ operationAllowlistRule({ families: ["read", "write"] }), { ruleType: "require_approval", ruleBody: { effect: "require_approval", when: { method: ["POST", "PUT", "PATCH", "DELETE"] }, approval: { // Only approvers the administrator's rule names. approvers: ["ops-lead@example.com", "controller@example.com"], expires_in_seconds: 3600, channels: ["email"], }, }, }, ], });}// ... the job runs as the run agent (its own key is run.apiKey, returned once) ...// Do not delete the run agent here: its writes are still waiting for approval.// Record run.id against the run; the webhook deletes it once the run settles.await saveRunAgent("run_2026_11_06_01", run.id); // your storeThe require_approval rule rides the delegation, so the run agent never holds the grant without it. Within one rule, any listed approver can approve or deny, and each one is notified — there is no quorum, the first decision stands. Across rules it is different: every require_approval rule on the chain that matches a request is its own sign-off. Rules that name the same approvers are combined into one; rules that name different approvers each hold the request as a separate gate, and the request runs only when every gate is approved (a single denial cancels it). The Wallet shows such a request as “Every approver below must approve”. So if the account’s person also holds the connection under a rule of their own, a run’s write needs both the Slack approver and that person in the Wallet. expires_in_seconds is how long a held request waits; an undecided request expires, the agent’s call fails with the expiry, and the job re-issues it if the work is still wanted. A rule on a managed secret must name its approvers: one that names none is refused when it is made (managed-secret grants require at least one explicit approver). (On an OAuth connection a rule that names none routes to the connection’s owner, who decides it only in the Wallet.)
The run agent binds every call to the job (context={"run_id": ...} / context: { run_id } on its requests, as the HITL guide shows), which is what lets the Slack summary group a run’s held requests.
The Slack summary: one message per run
Section titled “The Slack summary: one message per run”Register an outbound webhook for the application, subscribed to approval.requested, pointing at your receiver (alter webhooks create ... --events approval.requested, or the dashboard’s card; this needs an organization admin). Each delivery names one held request. Do not post one Slack message per request: verify the delivery, read everything the run is waiting on at the version the approve will need, and post one summary per run to that account’s channel — how many writes, the totals they move, what will change — then store the run’s items and their request_digests for the decision.
import os
from alter_sdk import App, WebhookSignatureError, verify_webhook
app = App(api_key=os.environ["ALTER_APPROVALS_KEY"]) # approvals:read + approvals:decide
async def receive(body: bytes, headers: dict[str, str]) -> None: try: event = verify_webhook(body, headers, os.environ["ALTER_WEBHOOK_SECRET"]) except WebhookSignatureError: return # answer 400; never act on it if event.type != "approval.requested": return run_id = (event.data.get("context") or {}).get("run_id") if run_id is None: return # a request outside a run is the Wallet's to show
# Everything the run is waiting on. A list item's summary is the stored # one (method, host, path): it never carries the values a write sends. page = await app.list_approvals(run_id=run_id, status="pending") version = max((item.required_summary_version for item in page.items), default=1) # Read each request at that version: a status read sent a summary_version # serves the summary a decide judges, values included, and names the # version a decide needs right now. statuses = [ await app.get_approval_status(str(item.approval_id), summary_version=version) for item in page.items ] version = max((status.required_summary_version for status in statuses), default=version)
# Each item's approvers too: a reaction decides only that person's items. items = [ { "approval_id": str(item.approval_id), "request_digest": item.request_digest, "approvers": list(item.approvers), } for item in page.items ] lines = [ f"- {status.summary.get('method')} {status.summary.get('path_summary')} " f"{status.summary.get('parameters') or ''}" for status in statuses ] await post_run_summary( # your Slack client, to the account's own channel account_channel(run_id), f"Run {run_id} is waiting on {len(page.items)} writes:\n" + "\n".join(lines), ) await remember_run(run_id, items=items, summary_version=version) # your storeimport { App, WebhookSignatureError, verifyWebhook } from "@alter-ai/alter-sdk";
const app = new App({ apiKey: process.env.ALTER_APPROVALS_KEY! }); // approvals:read + approvals:decide
export async function receive(body: Buffer, headers: Record<string, string>): Promise<number> { let event; try { event = verifyWebhook(body, headers, process.env.ALTER_WEBHOOK_SECRET!); } catch (error) { if (error instanceof WebhookSignatureError) return 400; // never act on it throw error; } if (event.type !== "approval.requested") return 200; const runId = (event.data.context as { run_id?: string } | undefined)?.run_id; if (!runId) return 200; // a request outside a run is the Wallet's to show
// Everything the run is waiting on. A list item's summary is the stored // one (method, host, path): it never carries the values a write sends. const page = await app.listApprovals({ runId, status: "pending" }); let version = Math.max(1, ...page.items.map((item) => item.requiredSummaryVersion)); // Read each request at that version: a status read sent a summaryVersion // serves the summary a decide judges, values included, and names the // version a decide needs right now. const statuses = []; for (const item of page.items) { statuses.push(await app.getApprovalStatus(item.approvalId, { summaryVersion: version })); } version = Math.max(version, ...statuses.map((status) => status.requiredSummaryVersion));
// Each item's approvers too: a reaction decides only that person's items. const items = page.items.map((item) => ({ approvalId: item.approvalId, requestDigest: item.requestDigest, approvers: [...item.approvers], })); const lines = statuses.map( (status) => `- ${status.summary["method"]} ${status.summary["path_summary"]} ${JSON.stringify(status.summary["parameters"] ?? {})}`, ); await postRunSummary( // your Slack client, to the account's own channel accountChannel(runId), `Run ${runId} is waiting on ${page.items.length} writes:\n${lines.join("\n")}`, ); await rememberRun(runId, { items, summaryVersion: version }); // your store return 200;}A run’s requests arrive one delivery at a time, so the first delivery often lists one request and the next lists two; post the first summary and edit it as the run fills (the stored run keeps the message id). Every delivery for the same request keeps the same webhook-id, so a retried delivery does not post twice. A list item’s summary is only the request’s stored summary: its method, host and path, the connection’s label and the reason the agent gave. Each item’s required_summary_version is the version an approve of it needs (a request that carries values, such as a write with a body on a custom integration, needs 2 or more; a bodiless read needs 1). So the receiver reads each request with get_approval_status(..., summary_version=version) / getApprovalStatus(id, { summaryVersion }): that read serves the summary a decide judges, with the operation, its parameters and the values as sent (sensitive inputs masked), and its required_summary_version is what a decide needs right now. The message shows those values, and the run is stored at that version, so the person approves exactly what Slack showed them. The held request’s headers never reach the receiver.
The reaction handler: one reaction decides the run
Section titled “The reaction handler: one reaction decides the run”When a Slack user reacts ✅ or ❌ on the summary message, first verify the event came from Slack: check the request’s X-Slack-Signature against your Slack app’s signing secret over v0:{X-Slack-Request-Timestamp}:{body} (HMAC-SHA256) and refuse a timestamp older than five minutes, as Slack documents. Ignore a reaction from a bot user (users.info returns is_bot), your own bot included. Then resolve the person’s email through the Slack API (users.info on the reacting user), then decide that person’s items of the stored run in one call with the email as actor_email, the stored digests and the stored summary_version. Send only the items whose approvers include that email. A batch is all-or-nothing: an item another rule’s approvers hold (a second gate of an N-of-N request) makes the whole call approval_not_found, and nothing is decided. Ignore every other reaction, and every reaction from someone none of the run’s items lists.
from alter_sdk import App, BackendError
def mine(items: list[dict], email: str) -> list[dict]: """The items this person approves, as the batch call takes them.""" return [ {"approval_id": item["approval_id"], "request_digest": item["request_digest"]} for item in items if email in item["approvers"] ]
async def on_reaction(app: App, run_id: str, reaction: str, slack_user_id: str) -> None: if reaction not in ("white_check_mark", "x"): return # The Slack event's signature was checked against the signing secret # before this runs (your Slack framework's request verification). user = await slack_user_info(slack_user_id) # users.info via your Slack client if user.is_bot: return # a bot's reaction, your own included, decides nothing email = user.email.lower() run = await recall_run(run_id) # the items, digests and summary_version stored above if not mine(run.items, email): return # not one of the run's approvers: say nothing decision = "approved" if reaction == "white_check_mark" else "denied" try: try: decided = await app.decide_approval_batch( mine(run.items, email), decision=decision, actor_email=email, summary_version=run.summary_version, reason=f"Decided in Slack by {email}", ) except BackendError as error: if error.details.get("error") != "approval_summary_version_required": raise # A request needs a newer summary than the one posted: re-read the # run at that version, re-post it, then decide at it. run = await repost_summary( run_id, summary_version=error.details["required_summary_version"] ) decided = await app.decide_approval_batch( mine(run.items, email), decision=decision, actor_email=email, summary_version=run.summary_version, reason=f"Decided in Slack by {email}", ) except BackendError as error: code = error.details.get("error") if code == "approval_not_found": return # not one of the run's listed approvers: say nothing if code == "batch_changed": await post_to_thread(run_id, "This run changed; posting a fresh summary.") await repost_summary(run_id) # re-read and re-post; never retry blindly return if code == "approval_requires_end_user": await post_to_thread(run_id, "One of these needs the account owner, in the Wallet.") return if code == "step_up_required": await post_to_thread(run_id, "This rule needs a fresh sign-in; decide it in the Wallet.") return raise await post_to_thread( run_id, f"{decided.decision} by {decided.decided_by} ({decided.actor_assurance}): " f"{len(decided.decided)} requests.", )import { App, BackendError } from "@alter-ai/alter-sdk";
interface StoredItem { approvalId: string; requestDigest: string; approvers: string[];}
// The items this person approves, as the batch call takes them.function mine(items: StoredItem[], email: string) { return items .filter((item) => item.approvers.includes(email)) .map(({ approvalId, requestDigest }) => ({ approvalId, requestDigest }));}
export async function onReaction(app: App, runId: string, reaction: string, slackUserId: string): Promise<void> { if (reaction !== "white_check_mark" && reaction !== "x") return; // The Slack event's signature was checked against the signing secret // before this runs (your Slack framework's request verification). const user = await slackUserInfo(slackUserId); // users.info via your Slack client if (user.isBot) return; // a bot's reaction, your own included, decides nothing const email = user.email.toLowerCase(); let run: { items: StoredItem[]; summaryVersion: number } = await recallRun(runId); // stored above if (mine(run.items, email).length === 0) return; // not one of the run's approvers: say nothing const decision = reaction === "white_check_mark" ? "approved" : "denied"; const decide = () => app.decideApprovalBatch(mine(run.items, email), { decision, actorEmail: email, summaryVersion: run.summaryVersion, reason: `Decided in Slack by ${email}`, }); let decided; try { try { decided = await decide(); } catch (error) { if (!(error instanceof BackendError) || error.details["error"] !== "approval_summary_version_required") throw error; // A request needs a newer summary than the one posted: re-read the run // at that version, re-post it, then decide at it. run = await repostSummary(runId, { summaryVersion: Number(error.details["required_summary_version"]) }); decided = await decide(); } } catch (error) { if (!(error instanceof BackendError)) throw error; const code = error.details["error"]; if (code === "approval_not_found") return; // not one of the run's listed approvers: say nothing if (code === "batch_changed") { await postToThread(runId, "This run changed; posting a fresh summary."); await repostSummary(runId); // re-read and re-post; never retry blindly return; } if (code === "approval_requires_end_user") { await postToThread(runId, "One of these needs the account owner, in the Wallet."); return; } if (code === "step_up_required") { await postToThread(runId, "This rule needs a fresh sign-in; decide it in the Wallet."); return; } throw error; } await postToThread( runId, `${decided.decision} by ${decided.decidedBy} (${decided.actorAssurance}): ${decided.decided.length} requests.`, );}What each refusal means for the bot:
- Only listed approvers succeed. An email that is not one of a request’s approvers is refused with
approval_not_found, the same answer an unknown id gets, so a reaction from a bystander confirms nothing. The handler stays silent for it. The same answer comes back for a batch that includes an item the person does not approve, which is why the handler sends only their items. - N-of-N. When an item’s request is held by more than one rule, each gate is a separate item with its own approvers. The Slack approver’s reaction decides their gate; the request stays pending until every other gate is approved too (by its own approvers, through the API or in the Wallet), and the summary should say who else must sign.
approval_summary_version_required. An item needs a newer summary version than the one the run was posted at (the error’srequired_summary_versionnames it). This is documented behaviour, not a fault: the receiver normally reads at the required version, and the error follows when an integration’s operation catalog changed after the request was held, or for a request held before this release (whose recorded version predates the full summary). Re-read the run at that version, re-post the summary, then decide at it; never retry the same call or raise the version without re-reading.approval_requires_end_user. A rule the end user set on themselves, or a gate that lists no approver and so belongs to the connection’s owner, is decided only by that person, in the Wallet — whoever the bot names. Tell the thread and stop. Rules your launcher attaches, as above, name approvers and are decided through the API.batch_changed. One of the stored items changed since the summary was posted — decided in the Wallet, expired, voided or re-issued — so nothing flipped. Do not retry the same call: re-read the run (the list at the required version, as the receiver does), post the fresh summary, and let a new reaction decide it.- Asserted and verified. The handler names the person by email, so the decision is recorded
asserted_by_integration: Alter checked the email against the request’s approvers and trusts that the bot authenticated the person — Slack did, by the reacting user’s account. To recordverified_by_alterinstead, pass the person’s ID token from the application’s identity provider asactor_tokenrather thanactor_email; the HITL guide explains both and the limit of each. - Step-up. A rule that requires a fresh sign-in (
step_up_required) cannot be satisfied by an email, only by anactor_tokenissued recently enough, or in the Wallet. Tell the thread where to decide it.
Post the outcome back to the thread: the decision, who made it and how Alter knew them (decided_by / decidedBy, actor_assurance / actorAssurance), and how many requests it covered.
Following up: what the run did
Section titled “Following up: what the run did”Subscribe the same webhook to approval.decided, approval.executed, approval.failed and approval.expired, and update the summary message as each request resolves: denied (an approval.decided whose status is denied, which ends the request), executed (the provider answered; result_available_until says how long the result can be read), failed (execution_error says why), or expired (expiry_reason: the window elapsed, or the request was voided because the connection, the agent or the secret it used went away). A receiver that was down reads the same with list_approvals(run_id=..., updated_after=...) / listApprovals({ runId, updatedAfter }) on restart, as the HITL guide’s polling section describes; the webhook is the feed, the poll the fallback.
denied = event.type == "approval.decided" and event.data.get("status") == "denied"if denied or event.type in ("approval.executed", "approval.failed", "approval.expired"): data = event.data run_id = (data.get("context") or {}).get("run_id") if run_id is not None: await update_run_line( run_id, data["approval_id"], data["status"], data.get("execution_error") or data.get("expiry_reason") or data.get("reason"), ) await clean_up_run_agent(run_id)const denied = event.type === "approval.decided" && event.data.status === "denied";if (denied || event.type === "approval.executed" || event.type === "approval.failed" || event.type === "approval.expired") { const data = event.data; const runId = (data.context as { run_id?: string } | undefined)?.run_id; if (runId) { await updateRunLine(runId, data.approval_id, data.status, data.execution_error ?? data.expiry_reason ?? data.reason); await cleanUpRunAgent(runId); }}Clean up the run agent
Section titled “Clean up the run agent”Once the job has finished and no request in its run is still in flight, nothing needs the run agent any more: delete it. Every terminal event above (a denial included) calls this check, and finish_job / finishJob calls it once the job stops, so the run agent goes as soon as its last write settles. Deleting it earlier would cancel what is still waiting; never deleting it would leave a live agent behind, which is why its delegation also carries an expiry.
Judge “in flight” by each item’s group_status / groupStatus, not its status. An item is one gate of a request: when a request two rules hold executes, it runs through one gate, and the other gate keeps status approved (its approver’s decision) for good. group_status is what happened to the request (pending, approved, executing, then executed, failed, denied or expired), so a run is settled when no item’s group_status is pending, approved or executing.
Order matters at the end of the job. The job reads its results first — await_approval / awaitApproval with the run agent’s own key, which returns the provider’s answer once a write executes — and only then records itself finished: deleting the run agent revokes its key, and a job still waiting after that gets key_revoked. The bot’s key only follows the run (status, decider, outcome); the job is what consumes the results.
from alter_sdk import ( Agent, ApprovalDeniedError, ApprovalExecutionFailedError, ApprovalExpiredError,)
async def finish_job(run_id: str, run_agent_key: str, held: list[str]) -> dict[str, object]: agent = Agent(api_key=run_agent_key) outcomes: dict[str, object] = {} try: for approval_id in held: # Each held write's result, as the provider answered it, or why # it did not run: one denied or expired write never stops the rest. try: outcomes[approval_id] = await agent.await_approval(approval_id, timeout=3600) except ApprovalDeniedError: outcomes[approval_id] = "denied" except ApprovalExpiredError: outcomes[approval_id] = "expired" except ApprovalExecutionFailedError as error: outcomes[approval_id] = f"failed: {error}" finally: await agent.close() # Always, even when a wait raised: the job's code has stopped, and the # run agent must not outlive it. await mark_run_finished(run_id) # your store await clean_up_run_agent(run_id) # a run whose writes all settled already return outcomesimport { Agent, ApprovalDeniedError, ApprovalExecutionFailedError, ApprovalExpiredError,} from "@alter-ai/alter-sdk";
export async function finishJob(runId: string, runAgentKey: string, held: string[]): Promise<Map<string, unknown>> { const agent = new Agent({ apiKey: runAgentKey }); const outcomes = new Map<string, unknown>(); try { for (const approvalId of held) { // Each held write's result, as the provider answered it, or why it did // not run: one denied or expired write never stops the rest. try { outcomes.set(approvalId, await agent.awaitApproval(approvalId, { timeoutMs: 3_600_000 })); } catch (error) { if (error instanceof ApprovalDeniedError) outcomes.set(approvalId, "denied"); else if (error instanceof ApprovalExpiredError) outcomes.set(approvalId, "expired"); else if (error instanceof ApprovalExecutionFailedError) outcomes.set(approvalId, `failed: ${error.message}`); else throw error; } } } finally { await agent.close(); // Always, even when a wait threw: the job's code has stopped, and the run // agent must not outlive it. await markRunFinished(runId); // your store await cleanUpRunAgent(runId); // a run whose writes all settled already } return outcomes;}The check itself reads every item of the run (paging through next_cursor / nextCursor) and deletes the run agent with the launcher’s key, which holds agents:write; the bot’s approvals key cannot delete an agent.
IN_FLIGHT = {"pending", "approved", "executing"}launcher = App(api_key=os.environ["ALTER_LAUNCHER_KEY"]) # agents:write
async def clean_up_run_agent(run_id: str) -> None: if not await run_finished(run_id): # your launcher's record: the job's code has stopped return cursor = None while True: page = await app.list_approvals(run_id=run_id, cursor=cursor) # ``None`` from a backend that predates the field: treat it as in flight. if any(item.group_status is None or item.group_status in IN_FLIGHT for item in page.items): return cursor = page.next_cursor if cursor is None: break agent_id = await load_run_agent(run_id) # recorded by the launcher if agent_id is not None: await launcher.agents.delete(agent_id) await forget_run_agent(run_id)const IN_FLIGHT = new Set(["pending", "approved", "executing"]);const launcher = new App({ apiKey: process.env.ALTER_LAUNCHER_KEY! }); // agents:write
async function cleanUpRunAgent(runId: string): Promise<void> { if (!(await runFinished(runId))) return; // your launcher's record: the job's code has stopped let cursor: string | undefined; do { const page = await app.listApprovals({ runId, cursor }); if (page.items.some((item) => item.groupStatus === null || IN_FLIGHT.has(item.groupStatus))) return; cursor = page.nextCursor ?? undefined; } while (cursor); const agentId = await loadRunAgent(runId); // recorded by the launcher if (agentId) { await launcher.agents.delete(agentId); await forgetRunAgent(runId); }}A run that held no writes produces no webhook events; finish_job above deletes its run agent directly, after the same check.
Keys, permissions and the Slack token
Section titled “Keys, permissions and the Slack token”- The bot’s Alter key is an application key carrying
approvals:decideandapprovals:read.approvals:decideis granted by no wildcard and held by no agent key, and minting or rotating any key is an organization-admin action — have an admin mint this one, with an expiry and the bot’s egress addresses as its IP restriction, and keep it in your secret store. - The launcher’s key carries
agents:writeandgrants:writeonly (they include the matching reads; the scoped-access guide says whygrants:writeon an app key is broad, and how to confine it). It creates and deletes the run agents. - Registering, changing or rotating the webhook endpoint needs an organization admin; the receiver verifies every delivery with the endpoint’s
whsec_secret and rotates it from the dashboard oralter webhooks rotate-secret. - Keep the Slack bot token in your own secret store, or store it as an Alter managed secret and let the bot call Slack through
proxy_requestso the token never sits in the bot’s process. Either way the bot posts only to the account’s own channel — the channel is looked up from the run’s account, never taken from the payload.
Who approved what: the audit log
Section titled “Who approved what: the audit log”Every decision made this way is one event in the application’s audit log, in the developer portal: the request it decided, the person who decided it, shown as the event’s Who with how Alter knew them (asserted_by_integration for a reaction, shown as “named by the app’s integration”; verified_by_alter for a token or a Wallet decision), and the reason the bot recorded. The application key that made the decision is recorded on the approval itself (decided_via_api_key_id in alter approvals show --output json). The approval’s status read and its approval.decided event carry the same decided_by and actor_assurance, so the bot’s own thread, the Wallet’s history and the audit log agree on who decided. A refused decision — a bystander’s reaction, a stale batch — is recorded too, as a refusal naming the key that tried and why.
What’s next
Section titled “What’s next”Holding requests, deciding through the API, sensitive input and summary versions.
Give an agent scoped accessAgents, delegation with rules and tags, and the launcher’s keys.
Outbound webhooksRegistering an endpoint, verifying deliveries, every event’s payload.
Set policiesThe require-approval rule’s conditions, approvers and expiry.