Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.awaithumans.dev/llms.txt

Use this file to discover all available pages before exploring further.

When await_human() fires, the default path is a Slack message, an email, or a tab on the awaithumans dashboard. Embedding is a fourth option: render the same form inside your own product, signed into your own auth, on your own domain. You keep:
  • Your auth. The user is already logged into your app; they never see an awaithumans login screen.
  • Your shell. The form sits in an <iframe> you control — header, sidebar, branding, everything outside the form stays yours.
  • The audit trail. Every submission is recorded against an opaque sub identifier you choose, alongside the task’s full audit log.
Three pieces wire it together: your backend mints a short-lived token, your frontend drops the URL into an iframe, your frontend listens for task.completed.
1

Configure the server

Add two environment variables to your awaithumans server and create a service key.
2

Mint a token (backend)

On the partner backend, after await_human() creates the task, mint a short-lived JWT scoped to that task and the iframe’s origin.
3

Drop the iframe (frontend)

Render an <iframe> with src = embed_url, listen for task.completed, react.

1. Configure the server

Set two env vars on the awaithumans server:
AWAITHUMANS_EMBED_SIGNING_SECRET=$(openssl rand -hex 32)
AWAITHUMANS_EMBED_PARENT_ORIGINS=https://app.acme.com
  • AWAITHUMANS_EMBED_SIGNING_SECRET — HMAC key for the embed JWTs. 32+ bytes of random hex. Without it, the embed feature is off and POST /api/embed/tokens returns 404.
  • AWAITHUMANS_EMBED_PARENT_ORIGINS — comma-separated allowlist of iframe parent origins. Drives both the parent_origin check at mint time and the Content-Security-Policy: frame-ancestors header on /embed/*.
Then create a service key — the partner-side secret used to mint embed tokens:
awaithumans create-service-key --name "acme-prod"
# ✓ service key created
#   id:     0019e0...
#   name:   acme-prod
#   Save this key now — it will not be shown again:
#   ah_sk_27b5fc4889071608410cbf0b91476cf8aa68043a
Service keys are shown once at creation. Store them like database passwords — never commit them, never put them in frontend code. To rotate, create a new key, deploy, then awaithumans revoke-service-key <id>.

2. Mint a token (backend)

After your agent calls await_human(...) and you have a task.id, mint a token scoped to that task and the iframe’s parent origin.
import os
from flask import Flask, jsonify, session
from awaithumans import embed_token_sync

app = Flask(__name__)


@app.post("/api/start-approval")
def start_approval():
    # `task` is whatever object you got back from your earlier
    # await_human(...) call; in real code, look it up by user / order.
    task_id = "tsk_4f8a2c1e9b"
    user_id = session.get("user_id", "user_demo")

    embed = embed_token_sync(
        task_id=task_id,
        sub=f"acme:{user_id}",                  # opaque per-user identifier
        parent_origin="https://app.acme.com",
        api_key=os.environ["AH_SERVICE_KEY"],
        ttl_seconds=300,                        # default 300, max 3600
    )
    return jsonify({"approval_url": embed.embed_url})
The response contains everything your frontend needs:
{
  "embed_token": "eyJhbGciOi...",
  "embed_url":   "https://reviews.acme.com/embed?id=tsk_4f8a2c...#token=eyJhbGciOi...",
  "expires_at":  "2026-05-12T08:05:23+00:00"
}
The token lives in the URL fragment (#token=...), not the query string. Fragments are never sent in HTTP requests or written to access logs — the iframe reads location.hash client-side and forwards the token in an Authorization header.

3. Drop the iframe (frontend)

<iframe
  id="approval"
  style="width: 100%; border: 0;"
  allow="clipboard-write"
></iframe>

<script>
const iframe = document.getElementById("approval");

// Pull the embed URL from your backend (the route from step 2)
// and point the iframe at it.
fetch("/api/start-approval", { method: "POST" })
  .then((r) => r.json())
  .then((d) => {
    iframe.src = d.approval_url;
  });

function continueAcmeFlow(response) {
  // Your continuation — e.g. update the order page with the decision.
  console.log("approved:", response.approved, "reason:", response.reason);
}

function handleError(code, message) {
  console.error(`[awaithumans] ${code}: ${message}`);
}

window.addEventListener("message", (e) => {
  // Only trust messages from your awaithumans server's origin
  if (e.source !== iframe.contentWindow) return;
  if (e.data?.source !== "awaithumans") return;

  switch (e.data.type) {
    case "loaded":
      // Task fetched and rendered. Safe to remove your own loading state.
      break;
    case "resize":
      iframe.style.height = e.data.payload.height + "px";
      break;
    case "task.completed":
      // e.data.payload.response is the typed object the user submitted.
      continueAcmeFlow(e.data.payload.response);
      // You decide what happens next: hide the iframe, navigate, swap UI.
      break;
    case "task.error":
      handleError(e.data.payload.code, e.data.payload.message);
      break;
  }
});
</script>
After a successful submit the iframe also shows a built-in “Submitted” panel, so users get inline confirmation even if your task.completed handler is slow to update the surrounding page.

Event protocol

The iframe posts messages to window.parent with targetOrigin pinned to the JWT’s parent_origin claim. Every message includes source: "awaithumans" so you can multiplex multiple iframes through one listener.
typeWhen it firespayload
loadedTask fetched, form rendered{ taskId }
resizeIframe content height changed{ height }
task.completedUser submitted, server accepted{ taskId, response, completedAt }
task.errorAnything failed (load, submit, expired token){ taskId, code, message }

Error codes on task.error

CodeMeaning
INVALID_EMBED_TOKENSignature failed, expired, or missing from URL
EMBED_ORIGIN_NOT_ALLOWEDparent_origin not in the server allowlist
SERVICE_KEY_NOT_FOUNDService key revoked or unknown (mint endpoint)
TASK_NOT_FOUNDTask ID in the token doesn’t exist
TASK_ALREADY_TERMINALTask was already completed/cancelled
internalNetwork failure or unhandled exception

Origin allowlist

AWAITHUMANS_EMBED_PARENT_ORIGINS is comma-separated and matched exactly on scheme, host, and port.
https://app.acme.com               # exact origin
https://*.acme.com                 # any single-label subdomain
http://localhost:3000              # dev only — http allowed on localhost/127.0.0.1
AllowedReason
Multiple wildcards (https://*.*.acme.com)❌ Rejected at server startup
Trailing slashes❌ Rejected at server startup
Scheme mismatch (http:// vs https://)❌ Mint returns 400
Port mismatch (acme.com vs acme.com:8080)❌ Mint returns 400
The same list drives the iframe’s CSP frame-ancestors, so browsers refuse to render the iframe inside any other site.

Security model

ah_sk_... is the partner secret that authorises minting. Never put it in browser code, mobile app bundles, public env files, or build artifacts. Treat it like a database password.
Whatever you pass as payload to await_human() is rendered to the human reviewing the task. Don’t put internal-only data, secrets, or PII the partner doesn’t want to expose. Use redact_payload=True on task creation to keep payload server-side only.
awaithumans records whatever sub you pass at mint time into the audit row as embed_sub. We don’t verify the identity — that’s the partner’s job (e.g., extract from your own session cookie before minting).
https://app.acme.com and https://acme.com are different origins. The server signs the parent_origin into the token, the iframe posts messages with that exact origin, and the browser drops anything mismatched.
Mixed content (http:// iframe inside https:// parent) is blocked by every modern browser. The only http:// origins that work at all are localhost and 127.0.0.1 for local dev.
Default TTL is 300 seconds, max 3600. Each token is bound to one task_id — a leaked token can’t be used to enumerate other tasks. Tampered tokens fail signature verification and return 401 INVALID_EMBED_TOKEN.

End-to-end example

A runnable Flask demo lives at examples/embed/ — a partner backend that creates a task, mints an embed URL, and a parent HTML page that hosts the iframe and listens for task.completed.

Troubleshooting

Iframe loads but shows “Authentication required”/api/embed/tokens returned 401. The service key is wrong, revoked, or AWAITHUMANS_EMBED_SIGNING_SECRET isn’t set on the server. Iframe loads but shows a 404 page — the dashboard isn’t bundled into your server image. Self-hosters need to run scripts/build-bundled.sh before pip install. The official wheel ships pre-bundled. task.error with EMBED_ORIGIN_NOT_ALLOWED — the parent_origin you passed at mint time isn’t in AWAITHUMANS_EMBED_PARENT_ORIGINS. Schemes and ports must match exactly. postMessage events never fire on the parent — the parent page’s origin doesn’t match the JWT’s parent_origin. Open browser devtools; the iframe page will log the mismatch.