Skip to content

Integration Guide

Four steps: three on the agent side, one on your BFA (the backend that receives the resume). See Architecture & Lifecycle for how these connect end-to-end.

1. Decorate your node

import hgateway_sdk as hg

@hg.hitl_node
def review_node(state):
    ...

There's no setup call before this — raise_interrupt auto-initializes the gateway client from HGATEWAY_AGENT_API_KEY on first use. You only need hg.init(transport=...) if you're injecting a custom transport (e.g. a fake for tests); see Client & Decorators.

@hitl_node binds the current LangGraph state, config, and an occurrence counter into contextvars, so the SDK can build the RuntimeContext automatically when you call raise_interrupt inside it. Calling raise_interrupt outside a @hitl_node-decorated node raises HitlNodeRequired — this decorator always comes first.

2. Build the interrupt content

Every interrupt is one of 4 families (HitlType): Approval, Decision, Context, Edit. Each family has a couple of concrete content classes (named {SubType}{Family}Content), and each pairs with a typed response TypedDict you read by key. One worked example per family:

Approval — BinaryApprovalContent

from hgateway_sdk import BinaryApprovalContent, Choice

content = BinaryApprovalContent(
    prompt=f"Approve this action? {state['summary']}",
    choices=[Choice("approve", "Approve"), Choice("reject", "Reject")],
)
# response: BinaryApprovalResponse -> {"decision": str}

Decision — SingleDecisionContent

from hgateway_sdk import SingleDecisionContent, Option

content = SingleDecisionContent(
    prompt="Which region should we deploy to?",
    options=[Option("us", "US"), Option("eu", "EU")],
)
# response: SingleDecisionResponse -> {"selected": str}

Context — FreetextContextContent

from hgateway_sdk import FreetextContextContent, FreetextInput

content = FreetextContextContent(
    prompt="What is the customer's preferred resolution?",
    input=FreetextInput(label="Customer preference", placeholder="Describe in a few sentences…"),
)
# response: FreetextContextResponse -> {"value": str}

Edit — ContentEditContent

from hgateway_sdk import ContentEditContent

content = ContentEditContent(
    prompt="Review the generated email and edit if needed before sending.",
    draft="Hi Jane,\n\nWe're following up on your recent support request…\n\nBest, Support Team",
)
# response: ContentEditResponse -> {"content": str}

The remaining 6 leaf classes (ModifyApprovalContent, MultiDecisionContent, RankDecisionContent, FormContextContent, ConfirmContextContent, ToolArgsEditContent) follow the same pattern — see Content Schemas for the full table.

3. Raise the interrupt

resp = hg.raise_interrupt(
    "approve-action",
    content,
    features=RunFeatures(routing=Routing(primary=Recipients(channels=["#ops"]))),
    fallback={"decision": "reject"},
)
return {"decision": resp["decision"]}
  • hitl_key ("approve-action") identifies this interrupt site. It should stay the same across resumes of the same node — it's part of the gateway's dedup identity, together with the LangGraph checkpoint namespace.
  • features is optional. RunFeatures(routing=...) tells the gateway which channel/user to notify; add ttl=Ttl(seconds=..., on_expiry=...) to get escalation for free. See Features & Routing.
  • Ownership vs fallback — if the gateway accepts, it owns the interaction (routing, notification, TTL) and your graph suspends until a human responds. If the gateway is unreachable or declines, the SDK falls back to a local interrupt(fallback) instead — your own backend can resolve it, and the agent still works without a hard dependency on the gateway.

4. In your BFA: don't double-handle a gateway-owned interrupt

Your BFA (the backend that owns the LangGraph checkpointer — see Architecture & Lifecycle) will see every suspended graph, including ones the gateway has taken over. Check the interrupt value before rendering your own UI or resolving it yourself:

state = graph.get_state(config)
for task in state.tasks:
    for i in task.interrupts:
        if i.value.get("__hgateway_owned__"):
            # The gateway is driving this one — routing, notification, TTL,
            # and collecting the response. Don't render anything; just wait
            # for the gateway's signed resume callback (see Architecture & Lifecycle).
            continue
        # Not gateway-owned (declined/unreachable fallback, or a plain local
        # interrupt) — handle it yourself as you normally would.

If you skip this check, a fallback-unaware BFA will try to resolve an interrupt the gateway already owns, racing the gateway's own resume.

Common pitfalls

  • HitlNodeRequiredraise_interrupt called outside an @hitl_node node. Decorate the calling node.
  • GatewayBootstrapErrorHGATEWAY_AGENT_API_KEY isn't set. Set the env var or provide a .env.
  • TtlForwardNeedsSecondary — you set Ttl.on_expiry=OnExpiry.FORWARD_TO_SECONDARY without also setting Routing.secondary — the gateway has nowhere to escalate to.

See Client & Decorators and Constants & Exceptions for the full API surface.