Build a support tool that takes a payment ID and answers "why did this payment fail?" from your payments API and your status page. The agent can only read: it cannot refund, retry or change anything. Setup takes about fifteen minutes.
A run like this usually takes 10 to 15 seconds. Most of the cost is compute, billed at $0.0002 per second, and each page the agent fetches costs $0.001. The share of the model depends on the model you choose, so read cost_micros on your first runs. The budget of $0.50 is a ceiling, not a price.
Gather what you need
- An Ephemeral account with credits, and two API keys from API keys: a member key for your backend, which starts runs, and an admin key for the one-time setup of the connection. See roles and keys.
- The base URL of your payments API (HTTPS, reachable from the internet) and a read-only key for it, such as a restricted key that can only read payments.
- A public status page on one domain, here
status.example.com. - An HTTPS endpoint on your server that will receive the result.
export EPHEMERAL_URL=https://app.ephemer.al export EPHEMERAL_API_KEY=eak_… # member key, for your backend export EPHEMERAL_ADMIN_KEY=eak_… # admin key, for setup only export EPHEMERAL_CALLBACK_SECRET=$(openssl rand -hex 16) # 32 characters; keep it on your serverWrite and upload the playbook
The playbook is where your team's knowledge goes: which calls to make and how to read the answer. Keep it short and concrete. The agent receives it as a read-only file next to the task.
# Failed payment playbook You can only read. The payments connection allows GET, and web access is limited to status.example.com. 1. GET /payments/{payment_id}. Note status, amount, currency, failure_code and failure_message. 2. GET /payments/{payment_id}/attempts. Check whether earlier attempts failed for the same reason. 3. If failure_code is processor_error or timeout, read https://status.example.com for incidents around that time. 4. Decide the cause: - card_declined, insufficient_funds, expired_card: the customer's bank or card. Suggest a retry or another card. - processor_error, timeout: ours or the processor's. Say whether the status page shows an incident. - fraud_review: a risk rule held it. Do not suggest bypassing it. 5. Never guess. If a call fails or a field is missing, say so. Answer in at most six lines: the cause, the evidence (field and value) and the next step for the support agent. Do not include card numbers or customer email addresses.curl $EPHEMERAL_URL/v1/files \ -H "Authorization: Bearer $EPHEMERAL_API_KEY" \ -F [email protected]The file is stored under the name
playbook.md. Uploading it again adds a new version, and workflows that name the file pick up the latest one when a run starts. See files.Create the payments-api connection
A connection holds the API's address and key inside Ephemeral. The agent calls it by name and never sees the key. Set
allowed_methodsto["GET"]so that every other method is refused.curl -X PUT $EPHEMERAL_URL/v1/connections/payments-api \ -H "Authorization: Bearer $EPHEMERAL_ADMIN_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "http", "url": "https://api.example.com/v1", "auth": { "type": "bearer", "token": "rk_live_…" }, "allowed_methods": ["GET"], "instructions": "Look up a payment with GET /payments/{id} and its attempts with GET /payments/{id}/attempts. Amounts are in cents." }'Prefer the dashboard? Open Connections, choose Add connection and HTTP API, enter the name
payments-apiand the base URL, pick Bearer token and paste the key, then select onlyGETunder Allowed HTTP methods. Theinstructionstell the agent which endpoints exist; they are shown to it, so keep secrets out.Reading the connection back shows no token, and
GET /v1/catalog/connectionslists it to members with its type and allowed methods, without the URL or any credential.Save the workflow
A workflow keeps the task, the playbook, the connection and the limits under one name, so your backend only sends the payment ID. The agent sees the connection as
payments. Web access is limited to the status page, and the shell is off because the task needs no commands.curl -X PUT $EPHEMERAL_URL/v1/workflows/investigate-payment \ -H "Authorization: Bearer $EPHEMERAL_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "prompt": "Find out why this payment failed.", "files": [{ "file": "playbook.md" }], "connections": { "payments": { "ref": "payments-api", "allowed_methods": ["GET"] } }, "web": { "allow": ["status.example.com"] }, "shell": false, "timeout_seconds": 120 }'No
modelis set, so each run uses the default model at the time it starts; pick another from the model catalog if you prefer. The payment ID, the budget and the callback belong to each run, not to the workflow.Start a run from your backend
Send the workflow name, the payment ID, the budget and a callback. The
Idempotency-Keymakes a retry safe: a repeated key returns the run that already exists with200instead of starting a second one. Use a key you can rebuild, such as the payment ID or your ticket ID.const paymentId = "pay_123"; const response = await fetch(`${process.env.EPHEMERAL_URL}/v1/runs`, { method: "POST", headers: { Authorization: `Bearer ${process.env.EPHEMERAL_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `investigate-${paymentId}`, }, body: JSON.stringify({ workflow: "investigate-payment", input: { payment_id: paymentId }, max_cost_micros: 500000, callback: { url: "https://example.com/hooks/ephemeral", secret: process.env.EPHEMERAL_CALLBACK_SECRET, }, }), }); const run = await response.json(); // { id: "run_…", status: "queued", … }The response is
202 Acceptedwith the run object, in statusqueued. The run starts as soon as a worker is free. The callback URL must use HTTPS and resolve to a public address, and a secret must be 24 to 1,024 characters; otherwise the request fails with400and nothing is started. On the hosted service402means your credits do not cover the budget.Verify the callback
When the run reaches a final status, Ephemeral sends one signed
POSTwith the run object. Check the signature against the raw bytes of the body before you trust anything in it. The signed text is theX-Ephemeral-Timestampheader, a period and the body, signed with HMAC-SHA256 using your secret. See callbacks for the full scheme.import crypto from "node:crypto"; import http from "node:http"; const handled = new Set(); // use your database in production http .createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const body = Buffer.concat(chunks); const timestamp = req.headers["x-ephemeral-timestamp"] ?? ""; const signature = req.headers["x-ephemeral-signature"] ?? ""; const expected = "sha256=" + crypto .createHmac("sha256", process.env.EPHEMERAL_CALLBACK_SECRET) .update(timestamp + ".") .update(body) .digest("hex"); const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; if (!valid || !fresh) return res.writeHead(401).end(); const run = JSON.parse(body); if (!handled.has(run.id)) { handled.add(run.id); // Attach run.output to the support ticket for run.id here. } res.writeHead(204).end(); // any 2xx counts as delivered }); }) .listen(3000); // serve this behind HTTPS at /hooks/ephemeralAny non-
2xxanswer is retried with growing delays, so the same run can arrive twice. That is why the handler remembers run IDs.Read the result
The body of the callback is the run object. For the example payment it looks like this:
{ "id": "run_118622d16f00105f7bba6d2281774705", "status": "succeeded", "output": "- **Cause:** The customer’s bank declined the payment for insufficient funds.\n- **Evidence:** `failure_code: insufficient_funds`; `failure_message: The card issuer declined the charge: insufficient funds.`\n- **Attempts:** Both attempts failed for the same reason.\n- **Next step:** Suggest the customer retry after resolving the balance issue or use another card.", "created_at": "2026-10-03T09:14:05.118Z", "started_at": "2026-10-03T09:14:05.402Z", "finished_at": "2026-10-03T09:14:16.447Z", "max_cost_micros": 500000, "cost_micros": 2618, "billing_pending": false, "zero_data_retention": false }outputis the agent's answer as text.cost_microsis the whole cost in millionths of a dollar, here $0.002618. A run that did not succeed hasstatusfailed,timed_outorcancelledand anerrorinstead of an output; show your agent that rather than nothing. Ifbilling_pendingistrue, the cost is still provisional: read the run again withGET /v1/runs/{id}a little later. To see each call the agent made, open the run in the dashboard or stream its events.
Why it's safe
| Risk | What stops it |
|---|---|
| The agent changes or refunds a payment. | The connection and the workflow both allow only GET; any other method is refused with HTTP method not permitted. |
| The payments key leaks. | The key stays in Ephemeral. The microVM has no network and no key; the broker adds the key to each request and removes credentials from responses. |
| Data goes to an unknown site. | Web access allows only status.example.com, and redirects are checked against the same list. See web access. |
| The agent runs commands. | shell is false. |
| A run costs more than expected. | The budget of 500,000 micros ($0.50) is reserved at the start and cannot be exceeded; timeout_seconds ends the run after 120 seconds. |
| Someone forges the result. | Your endpoint accepts only requests whose signature verifies. |