agent-wait
Get a LangGraph interrupt out of the process, and the answer back in.
When a node calls interrupt(), the graph pauses and the interrupt is handed to whatever
called invoke() — and that is where LangGraph stops. There is no built-in way to tell
anyone else that a question was asked, and no built-in way for anyone else to answer
it. The moment the question has to reach a person on Slack, an approvals dashboard, a
ticket queue or another service, you are writing that code yourself — whether your agent
is a server that runs for a year or a Lambda that is gone in seconds.
agent-wait is that code. It takes the interrupt and puts it somewhere people can see it —
a topic, a queue, a webhook, a database row — with everything needed to answer it in one
envelope, and documents the shape of the answer so the return leg is one if.
pip install agent-wait langgraph-wait # core + LangGraph
pip install agent-wait-aws # SNS / SQS / EventBridge / DynamoDB announcers
The whole thing
In the graph — one decorator, on the function where the decision belongs:
from agent_wait import WaitPolicy
from langgraph_wait import hitl
FINANCE = WaitPolicy(
timeout="P3D",
default={"action": "reject"},
allowed_actions=("approve", "reject"),
tags={"approver_group": "finance"},
)
@tool
@hitl(FINANCE)
def issue_refund(order_id: str, amount: int) -> str:
payments.refund(order_id, amount) # runs only if the answer is {"action": "approve"}
return "refunded"
Calling issue_refund(...) inside a run parks the graph on {"function": "issue_refund", "args": {...}}. On resume, {"action": "approve"} runs the body — optionally with edited
args — and anything else is returned in its place, so a model sees why it did not run.
A node that needs the answer itself declares a decision parameter and always runs:
@hitl(FINANCE)
def review(state, decision=None):
return {"decision": decision} # verbatim: {"action": "approve", "note": "ok"}
After the run — one call:
from agent_wait_aws import SnsAnnounce
from langgraph_wait import publish_interrupts
result = graph.invoke(value, {"configurable": {"thread_id": thread_id}})
publish_interrupts(result, thread_id, announce=[SnsAnnounce(topic_arn)])
It reads result["__interrupt__"] — Interrupt.id and Interrupt.value, nothing else —
and hands one envelope per question to the announcers. No graph handle, no get_state(),
no checkpointer. If nothing interrupted, it does nothing. A plain interrupt(value) from
a node that never heard of this library is published too, with the default policy.
When the answer comes back — the one if you write:
from langgraph.types import Command
if "question_id" in message: # an answer
value = Command(resume={message["question_id"]: message["answer"]})
else: # a start
value = message["input"]
result = graph.invoke(value, {"configurable": {"thread_id": message["thread_id"]}})
publish_interrupts(result, message["thread_id"], announce)
That is the complete integration. A duplicate answer runs nothing — LangGraph ignores a resume for a question the thread has moved past — so there is nothing to check.
Async mode — the thread does not park
@tool
@hitl(FINANCE, mode="async", announce=[SnsAnnounce(topic_arn), DynamoDbAnnounce(table)])
def issue_refund(order_id: str, amount: int) -> str: ...
The decorator is the publisher: the call announces the question and returns
{"status": "pending_approval", "question_id": ...} without running. The graph carries
on; nothing is parked; nothing to call after the run. The decision arrives later as a new
message and your graph acts on it. This is the mode for a single-thread channel like
WhatsApp, where the approver is not the person on the thread.
What goes out
{
"type": "wait.created",
"thread_id": "order-4471",
"question_id": "a1b2c3d4e5f60718",
"question": { "function": "issue_refund", "args": { "order_id": "order-4471", "amount": 41000 } },
"allowed_actions": ["approve", "reject"],
"expires_at": "2026-09-14T09:00:00Z",
"default": { "action": "reject" },
"source": { "function": "issue_refund" },
"reply_with": { "thread_id": "order-4471", "question_id": "a1b2c3d4e5f60718", "answer": null }
}
reply_with is a filled-in stub: copy it, set answer, send it back. Whatever goes in
answer is what the @hitl function receives — verbatim.
Full schema, the recommended answer shape, and how to deduplicate: Message formats.
Announcers
Subclass BaseAnnounce, write one method:
from agent_wait import BaseAnnounce
class RedisAnnounce(BaseAnnounce):
name = "redis"
def __init__(self, client, **kw):
super().__init__(**kw)
self.client = client
def deliver(self, envelope, transition):
self.client.set(envelope.dedupe_key, envelope.to_json())
A raise inside deliver() becomes a log line; the run completes. Shipped:
| Adapter | Package | Where the question lands |
|---|---|---|
WebhookAnnounce |
agent-wait |
A URL. JSON POST, optional HMAC-SHA256 signature. Stdlib only. |
LogAnnounce |
agent-wait |
A structured log line. The question never reaches INFO. |
InMemoryAnnounce |
agent-wait |
A list. For tests. |
SnsAnnounce |
agent-wait-aws |
A topic; tags become message attributes for subscription filters. |
SqsAnnounce |
agent-wait-aws |
A queue; on FIFO, grouped by thread, deduplicated on the stable key. |
EventBridgeAnnounce |
agent-wait-aws |
A bus. Notices partial failures behind a 200. |
DynamoDbAnnounce |
agent-wait-aws |
A row, status=open, with a GSI an approvals UI can query. Write-only. |
What the library does not do
- Receive answers. No endpoint, no validation, no ledger reads. The
ifabove is yours. - Enforce anything.
expires_at,default,answer_ttl,allowed_actionsare published so the consumer has the asker's intent. Acting on them is the consumer's. - Store anything. LangGraph's checkpoint is the only state in interrupt mode; in async mode there is none unless you keep one.
Two things to know
Not compatible with LangChain's HumanInTheLoopMiddleware. It interrupts before a
tool is called, in its own shape; @hitl interrupts inside the call. Both on one tool
means two interrupts for one approval. Use one or the other.
One interrupt() per node on langgraph 1.2.x. Two interrupting tools dispatched by
one ToolNode get the same interrupt id
(#6626), and only one surfaces
per run (#6624). Give each
interrupting tool its own node. Pinned by a test that fails if LangGraph changes it.
Layout
packages/agent-wait core: Question, WaitPolicy, the envelope, publish(), announcers. No deps.
packages/langgraph-wait @hitl and publish_interrupts(). The only LangGraph import.
packages/agent-wait-aws four announce adapters, and a CDK stack for the example.
examples/refund_agent a graph and a host, deployed to Lambda behind SQS.
docs/ message contract, architecture, announcer guide, consumer guide.
MIT. Issues and PRs at github.com/skamalj/agent-wait.
Metadata
Release files for agent-wait 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| agent_wait-0.4.0.tar.gz | 15.5 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| agent_wait-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 35.9 kB
Release files / agent_wait-0.4.0.tar.gz
| Download URL | agent_wait-0.4.0.tar.gz |
|---|---|
| Size | 15.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d85ff95ab89c0f7d5afbce9efca541efadd2ed26661f7fe5b90b01861c2c7a3f
|
|
BLAKE2b-256 checksum How to use checksums |
9cb51c7c141bdfbad86e23c890046d40191588cf7ae6d5c6f31b067e25796b6e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / agent_wait-0.4.0-py3-none-any.whl
| Download URL | agent_wait-0.4.0-py3-none-any.whl |
|---|---|
| Size | 20.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0eb57b2b1fb5e13dfb88c5ef4c77ebdaa9eff3780890380150626c44ff2a5142
|
|
BLAKE2b-256 checksum How to use checksums |
2d7ba9661fa93f8b32bbb0670d9a8c0097744c75948b0cb427c728324574e6bc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.23 {"installer":{"name":"uv","version":"0.11.23","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for agent-wait 0.4.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.