Describe it. Hand it off. It runs.
Handoff turns a sentence — spoken or typed — into an autonomous workflow on the Strands Agents SDK, runs it on a schedule on AWS, handles what it can judge confidently, and stops to ask you only about the things it genuinely can't call. Your answer becomes a rule, so it asks less every week.
Built for the Agents for Humans Hackathon, Professional Agents track. The brief asked for an agent that runs autonomously and only surfaces when there's a real decision to make. This repository is that sentence, with the receipts.
| Live site and guides | https://handoff-eya.pages.dev · docs |
| Pitch | deck (PDF) · deck (PPTX) · speaker script |
| Architecture | diagram (AWS icons) · draw.io source · PDF · ARCHITECTURE.md |
| Demo video | video/out/handoff-demo.mp4 · made by video/ and scripts/video/ from live footage of the real inbox — see The film |
- The sixty-second tour
- What the hackathon asked for, and where it is
- Say it, watch it run
- The gate
- Architecture
- Built on Strands — with proof
- On AWS — with proof
- Three surfaces: Talk, terminal, browser
- Run it yourself
- Verification and cost
- Layout
- Licence
Tap the orb and say: "Every weekday at eight, triage my inbox. Real asks from teammates become Linear tickets, newsletters get archived, ask me about anything unsure. Set it up and run it now."
Every screenshot in this README was taken from the running product between 13 and 15 September 2026 — the web UI through Playwright, the terminal through freeze, the AWS console state through the AWS CLI. Nothing is mocked up.
| Requirement | How Handoff meets it | Proof |
|---|---|---|
| Runs autonomously | A Strands Graph (trigger → executor → completer) runs on a cron schedule — in-process on the desktop, EventBridge → Lambda → AgentCore Runtime in the cloud |
run graph · aws scheduler / lambda |
| Surfaces only for real decisions | A BeforeToolCallEvent hook gates the one tool that changes anything; below the confidence threshold it defers, then asks once for the whole batch |
decision screen · hitl.py |
| Built on the Strands Agents SDK | Agent, Graph, hooks, event.interrupt(), SessionRepository, MCPClient, 16 @tools |
imports proof · table below |
| Uses AWS | Bedrock (Nova), AgentCore Runtime + Memory, Transcribe, Polly, DynamoDB, EventBridge, Lambda, ECR — all live in ap-northeast-2 |
handoff doctor · AgentCore · DynamoDB |
| Professional use | Inbox triage, PR review triage, competitor pricing watch, Slack digest, meeting follow-up — shipped as templates; anything else described in a sentence | workflows · discover |
| Learns the person | Each decision becomes a narrow rule (sender, domain, or two keywords); AgentCore Memory in the cloud, JSON locally; the next run asks less | decide → rule · memory |
| Real, not scripted | Verified end-to-end on Bedrock Nova: a spoken sentence ends as an active, running workflow; 8 items, 7 handled alone, 1 escalated | ask · inspect · usage |
Talk is the first page in the sidebar. The orb is a WebGL sphere whose
colour follows the agent — blue idle, teal listening, violet thinking, green
while a tool runs, and amber only when it is waiting on you. Tap it, hold
Space, or switch on hands-free and it ends each utterance on silence and
listens again after it answers. Ctrl+Space opens it from any page.
What a spoken turn produces is drawn on the page from the agent's own events:
- the workspace card when a workflow is saved — Signals (the cron and its plain-English reading), Jobs (what runs, where the line sits, where it reports), Agents (each MCP tool, the executor LLM, the completer SEND);
- the run graph when a run starts — the real Graph, a node per tool call, with milliseconds, until it lands on DONE or NEEDS YOU;
- the decision card when it stops — answerable with a click or a phrase.
Spoken decisions — archive it, file a ticket, draft a reply, leave it — are matched locally with no model round-trip. Speech streams to Amazon Transcribe over a WebSocket while you are still talking; Amazon Polly answers; Groq's Whisper and Orpheus are the second choice and the browser's own engines the floor, so nothing goes mute over a missing key.
The whole product is one file:
src/handoff/graph/hooks/hitl.py.
response = event.interrupt(
interrupt_name,
reason=payload.model_dump(mode="json"),
)A BeforeToolCallEvent hook sits in front of the only tool that changes
anything in the outside world. On the first pass, below the confidence
threshold, that line raises InterruptException and unwinds the agent loop
before the tool body runs — nothing has happened to your mail. When a
human answers, the same line returns their answer, and the tool carries out
what they chose.
Unsure items are deferred, not interrupted: the executor finishes its
pass, then finish_batch raises a single interrupt carrying all of them. You
get one screen, not one interruption per question. The Graph state is
serialised, so a run paused at 08:04 and answered at 11:30 is the same run —
it survives a restart.
What it is not: an approval prompt on every action. An agent that interrupts on everything is just a worse inbox.
Official AWS Architecture Icons, drawn in draw.io: the editable source is
docs/architecture.drawio (open it at
app.diagrams.net), generated by
scripts/make_drawio_architecture.py
and exported through draw.io's own renderer. The detailed architecture
document — this diagram plus the full design walkthrough — is
docs/architecture.pdf; the long-form text is
docs/ARCHITECTURE.md. An earlier
Excalidraw version is kept alongside.
Three agents, because the jobs are different:
| Agent | Does | Lives in |
|---|---|---|
| Builder / voice assistant | Turns a description into a workflow config; the spoken variant saves and starts it in one turn | agents/builder.py, chat/service.py, chat/voice_tools.py |
| Executor | Runs the workflow as a Graph; decides what it may do alone | graph/factory.py, graph/hooks/hitl.py |
| Learner | Turns each human decision into a narrow, reusable rule | agents/learner.py |
Why Graph and not Swarm: a workflow is a fixed, auditable sequence — wake
up, do the work, stop at anything unclear, close out. Graph models exactly
that. Swarm models open-ended collaboration, which would make every run take
a different path through the same job. For something filing tickets in your
name at 8am, "different every time" is a bug.
| SDK feature | Where it earns its place |
|---|---|
Agent |
the Builder, the voice assistant, the executor, the completer, the Learner, the chat |
@tool decorator |
16 custom tools, from fetch_unread_emails to activate_workflow |
BeforeToolCallEvent hook + event.interrupt() |
the gate — graph/hooks/hitl.py; resume returns the human's answer |
| Batch interrupt | finish_batch — one question for a whole pass |
Graph + GraphBuilder + conditional edges |
graph/factory.py; an empty webhook never costs a model call |
Graph.serialize_state |
a paused run survives a process restart |
BeforeInvocationEvent / AfterInvocationEvent / AfterToolCallEvent |
graph/hooks/narrator.py — one narrator per node; the orb draws the Graph from these |
SessionRepository + RepositorySessionManager |
chat/repository.py — chats persisted in the same store as everything else; the terminal and the browser share a session |
| Context variables into tools | chat/voice_tools.py — activate_workflow and start_run emit on the page's channel |
MCPClient (stdio + HTTP) |
mcp/servers.py — Gmail, Linear, Slack, GitHub, Notion, Airtable, web |
AgentTool subclass |
host/tools.py — a host runtime's tools as Strands tools |
OpenAIModel subclass |
providers.py — repairs malformed tool-call JSON |
| OpenTelemetry tracing | config.configure_observability() |
Built and verified against strands-agents 1.55.1 on real models
(Bedrock Nova Pro and Nova Lite, Groq qwen/qwen3.8-27b). The interrupt API
is version-sensitive — event.interrupt(name, reason=…) returns the human's
answer on resume — so pin the version before changing the gate.
The tests that matter (255 passing, lint clean)
tests/test_hitl_gate.py drives the gate inside a
real Strands agent and asserts the things that actually matter: an unsure call
stops the loop and the tool never runs; resuming executes what the human
chose, not what the agent suggested; "leave it" cancels the tool; a malformed
confidence score fails toward asking, never toward acting.
tests/test_orb.py runs a whole spoken turn on the
scripted model and asserts it ends active and running.
Everything below is live in ap-northeast-2, captured with the AWS CLI on
2026‑09‑13 (account id masked).
| Service | Purpose | Proof |
|---|---|---|
| Amazon Bedrock — Nova Pro / Nova Lite | reasoning for every agent, through cross-region inference profiles | inference profiles + doctor |
| AgentCore Runtime | serverless background execution, arm64 container, session per run, long-running invocations | list-agent-runtimes → READY |
| AgentCore Memory | learned preferences across runs | list-memories → ACTIVE |
| Amazon Transcribe (streaming) | hears you — partial results while you speak | Polly voices + doctor speech |
| Amazon Polly (neural) | speaks back | same |
| DynamoDB | one table, pk = collection, sk = id — configs, runs, interrupts, sessions, usage, audit |
describe-table |
| EventBridge Scheduler → Lambda | cron triggers; Scheduler cannot target AgentCore directly, so a twelve-line function forwards the tick | schedules, function, ECR repo |
| ECR | the runtime's image | same |
| IAM | one execution role for the runtime, one for the scheduler with a single permission | infra/iam_setup.py |
Deploying is a handful of boto3 scripts, no console clicking:
python infra/deploy_agentcore.py --check # says exactly what is missing
python infra/deploy_agentcore.py # build (arm64) → ECR → AgentCore Runtime
python infra/memory_setup.py # AgentCore Memory store
python infra/dynamodb_setup.py # the single table
python infra/iam_setup.py # the role EventBridge Scheduler assumes
python infra/lambda_setup.py --runtime-arn … # the bridge
python infra/eventbridge_setup.py --target-arn <lambda> --role-arn <role>Every store has a local JSON backend, so none of this is required to run, test or demo the project — only to deploy it. The site is the one thing not on AWS: static files on Cloudflare Pages.
Two things Bedrock tells you confusingly. Model ids are reached through a
cross-region inference profile prefixed by geography (us., apac., eu.),
and the prefix must match the calling region — Handoff resolves a bare id
against AWS_REGION. And Anthropic models on Bedrock are sold through AWS
Marketplace while Amazon's own are not, so an account that cannot complete a
Marketplace agreement gets INVALID_PAYMENT_INSTRUMENT on Claude in every
region and works on Nova. That is why the default is Nova Pro.
handoff desktop opens a native window (WebKitGTK, WebKit or WebView2 —
no Electron) on the orb, remembers its size and page, records the microphone
itself if the webview will not, and attaches to an already-running instance
instead of starting a second scheduler.
Every feature is a command. handoff chat streams the same assistant into
your terminal and the conversation continues in the browser (one session
repository); handoff build "<sentence>" --activate --run turns a sentence
into an active workflow; handoff talk --mic is the orb without the orb.
The platform around the gate — workspaces as workspace.yml, chat, activity,
runs and inspector, agents and a workbench, tool servers, skills with version
history, memory, schedules, usage, settings, a welcome wizard — is documented
in the guides and in-app at /docs.
https://handoff-eya.pages.dev is a framework-free static site — the landing
page with the live orb, and the guides — built by site/build.py from
docs/site/*.md and published to Cloudflare Pages with make site-deploy.
![]() |
![]() |
Just to use it, on Windows, Linux or macOS — one command, no checkout:
pipx install "handoff[bedrock,desktop,voice,web]"
handoff desktopLinux needs GTK and WebKit from your distribution for the native window, and Windows needs nothing beyond Python. Both are spelled out, per distribution, in docs/INSTALL.md.
With nothing at all — the whole loop on a scripted model, no keys, no account:
git clone https://github.com/LSUDOKO/Handoff && cd Handoff
python3.12 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,bedrock,desktop,web,voice]"
make demoOn AWS — Bedrock for reasoning, Transcribe and Polly for voice, one
aws configure and no other keys:
cp .env.example .env # HANDOFF_MODEL_PROVIDER=bedrock, AWS_REGION=ap-northeast-2
make doctor # every check makes a real call
make desktop # native window, opens on the orb — or `make serve`With one free key (Groq): set HANDOFF_MODEL_PROVIDER=groq and
GROQ_API_KEY; Whisper hears and Orpheus speaks.
| Variable | Meaning |
|---|---|
HANDOFF_MODEL_PROVIDER |
bedrock · groq · anthropic — identical agent code, different model object |
HANDOFF_SPEECH_PROVIDER |
auto (AWS, then Groq, then browser) · aws · groq · browser |
BEDROCK_MODEL_ID / BEDROCK_FALLBACK_MODEL_ID |
bare ids; the geography prefix is added for AWS_REGION |
CONFIDENCE_THRESHOLD |
below this the gate asks instead of acting (default 0.7) |
USE_MOCK_TOOLS |
true = the synthetic eight-message inbox; false = real MCP servers |
USE_DYNAMODB / USE_AGENTCORE_MEMORY |
cloud state; false = local JSON |
Full credential walkthrough — Gmail's own OAuth sign-in, Linear, Slack, GitHub, Notion, Airtable
— in docs/SETUP.md. The CLI's --state-dir always means
local storage, so a scratch run can never touch the live table.
The demo video is produced, not edited by hand, so it can be re-made after any change:
- Live footage is live.
scripts/video/record_live.pylaunches Chromium with a fake microphone fed by a WAV of the spoken sentence, taps the real orb, and records the page while Amazon Transcribe hears it over the WebSocket, Bedrock Nova Pro runs the turn, the workspace card lands, the run graph fills in and the decision card appears. A second clip answers by voice; a third runs again with the learned rule. Every milestone is timestamped so the narration is cut against what happened. - Narration is Amazon Polly's generative voice, one clip per sentence
(
scripts/video/narrate.py); the sentence that quotes the decision's numbers is regenerated from the recorded state. - The composition is Remotion —
video/src: typographic scenes, the real screenshots, the Excalidraw diagram panned to what is being said, captions, a music bed.scripts/video/build_cues.pyturns the recorded timelines into cut points. - The deck cut is the Marp deck narrated slide by slide and stitched with
ffmpeg (
scripts/video/deck_video.py).
python scripts/video/narrate.py <work>/audio # Polly, per sentence
python scripts/video/record_live.py <work>/audio <work>/clips
python scripts/video/build_cues.py <work>/clips <state-dir> <work>/audio
scripts/video/prepare_assets.sh <work> && scripts/video/render.sh
python scripts/video/deck_video.py <work>/deck out/handoff-pitch-deck.mp4The MP4s are not committed; they are uploaded with the submission.
| Tests | 255 passing · ruff clean |
| Doctor | Bedrock, Speech, DynamoDB, AgentCore Memory green; Gmail, Linear, Slack, GitHub, Notion, Airtable skipped until keys exist |
| Real-model check | a spoken set-up on Nova Lite ends active and running in two of two attempts; 8 items, 7 handled alone, 1 escalated |
| Speech round trip | Polly said a sentence, Transcribe returned it word for word |
| Spend | a spoken turn on Nova Lite costs under a tenth of a cent; Transcribe is $0.024/min, Polly $16 per million characters; a full demo take on Nova Pro is cents |
src/handoff/
├── graph/hooks/hitl.py ⭐ the interrupt gate
├── graph/hooks/narrator.py node and tool timings on the events bus — what the orb draws
├── graph/factory.py the Strands Graph
├── graph/nodes/ trigger, executor, classifier, gate, completer
├── agents/ builder, executor runner, learner
├── chat/ persisted chats, streaming service, voice tools
├── speech/ Transcribe + Polly, Whisper + Orpheus, WAV framing — one facade
├── cli/ every feature from the terminal, one module per command group
├── platform/ workspaces, credentials, skills, agents, workbench, MCP, usage, workspace.yml
├── web/ FastAPI + HTMX + Jinja — the shell and every page; orb.js / talk.js / work-panel.js
├── docs.py the guides, rendered for the app and the site
├── host/ embed Handoff in another agent runtime
├── workflows/ five shipped templates
├── memory/store.py AgentCore Memory + local preferences
├── mcp/servers.py MCP registry
├── tools/voice.py spoken commands, matched without a model
├── providers.py resilient OpenAI-compatible provider
├── events.py live run feed (SSE)
├── desktop.py native window with a microphone bridge
├── doctor.py real-call credential checks
└── app.py AgentCore Runtime entrypoint
docs/ ARCHITECTURE · SETUP · DEMO · SUBMISSION · pitch/ · site/ (guides) · screens/ · architecture.excalidraw
site/ the landing page and docs build, deployed to Cloudflare Pages
infra/ AgentCore, DynamoDB, Memory, IAM, Lambda, EventBridge — boto3, no console
scripts/ architecture generator and exporter, secret guard
tests/ 255 tests
Apache-2.0. See LICENSE and NOTICE.
All of it is original work built on the Strands Agents SDK. No third-party
source is vendored; every dependency is installed from its own distribution
and stays under its own licence. The orb's shader is adapted from
DORA (MIT) and the visual language from
Agent.md (MIT); both are credited
in NOTICE.















































