API reference¶
Generated from the source.
The interrupt node¶
kirokuforms.graph ¶
A real LangGraph interrupt, backed by a KirokuForms review page.
Why this exists¶
create_kiroku_interrupt_handler (in kirokuforms.kirokuforms) is not a
LangGraph interrupt. It creates the task and then sits in a polling loop inside
the node, by default for up to an hour. The graph is not suspended during that
hour; a worker is held open waiting for a human who may be asleep. It works, and
it is kept for callers who depend on it, but it is a blocking REST call wearing
the name of a mechanism it does not use.
LangGraph's actual mechanism is :func:langgraph.types.interrupt. It suspends
the graph, persists the state through a checkpointer, and costs nothing while it
waits. The run is resumed later with Command(resume=...), from a different
process if you like. That is what this module provides.
The re-execution rule, which is the thing to understand¶
When a graph is resumed, the interrupted node runs again from its first
line. interrupt() returns the resume value this time instead of
suspending, but everything above it has already happened once and is about to
happen again. A node that creates a task above its interrupt() call will
create a second task on resume unless something stops it.
Nothing about that is specific to us; it catches everyone once. Here it is
handled by giving each task a deterministic id derived from the graph's thread,
and sending it as an idempotency key. The server has had idempotent creates
since the MCP work landed: a repeat of the same key replays the case that was
already made, with the same formUrl and the same token, and creates no
second form, task or email. So the second execution finds the first task
instead of minting one.
The consequence is worth stating: the same node interrupting twice in one
thread, in a loop, would reuse one case. Pass task_id_for to make the id
depend on whatever distinguishes the iterations if you need that.
create_kiroku_interrupt_node ¶
create_kiroku_interrupt_node(client: KirokuFormsHITL, *, name: str = 'review', title: str = 'Human Verification Required', description: str = 'Please verify the following information', fields: Optional[List[Dict[str, Any]]] = None, data_key: Optional[str] = None, template_id: Optional[str] = None, task_id_for: Optional[Callable[[Dict[str, Any]], str]] = None, **create_kwargs: Any) -> Callable[..., Dict[str, Any]]
Build a graph node that asks a human and suspends until they answer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
KirokuFormsHITL
|
A configured :class: |
required |
name
|
str
|
Distinguishes this node's tasks from another node's in the same thread. It goes into the deterministic task id, so two review steps in one graph do not collide. |
'review'
|
title
|
str
|
Task title, shown to the reviewer. |
'Human Verification Required'
|
description
|
str
|
Instructions for the reviewer. |
'Please verify the following information'
|
fields
|
Optional[List[Dict[str, Any]]]
|
Field definitions, used to build a review page for this one task. |
None
|
data_key
|
Optional[str]
|
A key in the state whose dict is turned into fields by
:meth: |
None
|
template_id
|
Optional[str]
|
An existing form to use instead of either of the above.
Prefer this for a review step that runs often. |
None
|
task_id_for
|
Optional[Callable[[Dict[str, Any]], str]]
|
Override the deterministic id. Receives the state. Anything you return must be stable across a resume of the same logical step, because that is what stops a second task being made. Use it when one node interrupts more than once in a thread, where the default id would make both iterations share a case. |
None
|
**create_kwargs
|
Any
|
Passed through to |
{}
|
Returns:
| Type | Description |
|---|---|
Callable[..., Dict[str, Any]]
|
A node callable. Add it with |
The node writes its result into state["human_verification"] as
{"completed", "task_id", "form_url", "result"}, the same shape the older
handler uses.
Compile the graph with a checkpointer and invoke it with a thread_id, or
there is nothing to suspend into and nothing to resume::
graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "order-42"}}
graph.invoke({"amount": 100}, config) # stops at the interrupt
graph.invoke(Command(resume=answers), config) # carries on
Source code in kirokuforms/graph.py
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | |
resume_with_answers ¶
A Command that resumes a graph with what the human actually submitted.
The alternative is to resume with whatever the operator types, which makes the form decorative. This reads the answers back from the task.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client
|
KirokuFormsHITL
|
The same client the node used. |
required |
task_id
|
str
|
The |
required |
wait
|
bool
|
Block until the task is completed. |
False
|
Returns:
| Type | Description |
|---|---|
Command
|
|
Source code in kirokuforms/graph.py
The client¶
kirokuforms.kirokuforms.KirokuFormsHITL ¶
KirokuForms client for human-in-the-loop integration with LangGraph.
Source code in kirokuforms/kirokuforms.py
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 | |
__init__ ¶
__init__(api_key: str, base_url: str = 'https://www.kirokuforms.com/api/mcp', webhook_url: Optional[str] = None, webhook_secret: Optional[str] = None, timeout: int = 10, max_retries: int = 3)
Initialize the KirokuForms HITL client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_key
|
str
|
Your KirokuForms API key |
required |
base_url
|
str
|
The KirokuForms MCP API base URL |
'https://www.kirokuforms.com/api/mcp'
|
webhook_url
|
Optional[str]
|
Optional URL for webhook notifications |
None
|
webhook_secret
|
Optional[str]
|
Secret for webhook verification |
None
|
timeout
|
int
|
Request timeout in seconds |
10
|
max_retries
|
int
|
Maximum number of retries for failed requests |
3
|
Source code in kirokuforms/kirokuforms.py
create_task ¶
create_task(title: str, description: str = '', fields: Optional[List[Dict[str, Any]]] = None, template_id: Optional[str] = None, initial_data: Optional[Dict[str, Any]] = None, expiration: Optional[str] = None, priority: str = 'medium', task_id: Optional[str] = None, callback_url: Optional[str] = None, assign_to_email: Optional[str] = None, assignee_name: Optional[str] = None, assign_to_slack: Optional[bool] = None, idempotency_key: Optional[str] = None) -> Dict[str, Any]
Create a human-in-the-loop task.
Either fields or template_id is required.
Assignment forwards three keys into the MCP tool's settings:
assign_to_email: hand the task to this address. When it belongs to an existing KirokuForms account, the task is assigned to that account and the teammate is told in-app (free). When it does not, the recipient gets a tokenized link and needs no account, which is the paid feature: a key on a free plan is refused with a 402 (SUBSCRIPTION_REQUIRED).assignee_name: an optional display name for the recipient, shown in the notification.assign_to_slack: which channel reaches the assignee. Left unset (orTrue), the task is DMed on Slack when the address resolves to a Slack user in the owner's workspace, and emailed otherwise.Falseemails a workspace teammate instead of DMing them. Slack still has to be connected with the direct-assign scopes and the recipient present in the workspace for a DM to happen, and a tokenized link is only ever DMed, never posted to a shared channel.
idempotency_key is sent as the Idempotency-Key header. Repeating
a create with the same key replays the case the first call made, with
the same formUrl and the same access token, instead of minting a
second form, task, token and email. Use it whenever a create can run
twice for reasons outside your control: a retried request, a redelivered
queue message, or a LangGraph node re-executing after a resume (see
kirokuforms.graph). task_id works as one too and the server
honours either, but the header is the one that does not also change what
the task is called.
Source code in kirokuforms/kirokuforms.py
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | |
create_verification_task ¶
create_verification_task(title: str, description: str, data: Dict[str, Any], fields: Optional[List[Dict[str, Any]]] = None, **kwargs: Any) -> Dict[str, Any]
Create a verification task with data to be verified.
Source code in kirokuforms/kirokuforms.py
get_task_result ¶
Get the result (form data) of a HITL task.
A completed task returns the submitted form data, a flat dict of field name to answer.
A task that has not been completed returns a status report instead:
{"taskId", "status", "completed": False, "data": None}. That happens
when wait is False and the human has not answered yet, and when the
task reached a status it can never come back from (canceled,
expired), where waiting would only burn the timeout.
Raises:
| Type | Description |
|---|---|
TimeoutError
|
|
Source code in kirokuforms/kirokuforms.py
list_tasks ¶
List HITL tasks.
Source code in kirokuforms/kirokuforms.py
verify_webhook ¶
Check that a webhook came from KirokuForms, using webhook_secret.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
The raw request body (str or bytes), or a parsed dict. |
required |
signature
|
Optional[str]
|
The |
None
|
Returns:
| Type | Description |
|---|---|
Dict[str, Any]
|
The parsed payload. |
Raises:
| Type | Description |
|---|---|
WebhookVerificationError
|
unset secret, missing signature, or a digest that does not match. |
Until 2026-08-20 webhook_secret was accepted, documented as being
for verification, and read by nothing at all. A callback endpoint built
from the README was open to anyone who learned its URL.
Source code in kirokuforms/kirokuforms.py
cancel_task ¶
Webhook verification¶
kirokuforms.webhooks ¶
Verifying that a webhook really came from KirokuForms.
KirokuFormsHITL has accepted a webhook_secret argument since the first
release, documented as "Secret for webhook verification". It was stored on the
instance and never read: nothing verified anything. A caller following the
README believed their callback endpoint was authenticated when it was open to
anyone who learned the URL. This module is the missing half.
How KirokuForms signs¶
src/lib/services/webhook-service.ts computes::
HMAC-SHA256(secret, JSON.stringify(payload without its "signature" key))
hex-encoded, and sends it two ways: in the X-KirokuForms-Signature-256
header, and as a signature field inside the JSON body it delivers.
That means the signed bytes are not the bytes on the wire: the delivered
body has the signature added to it. A verifier has to parse the JSON, drop
signature, and re-serialize exactly the way JSON.stringify would.
Re-serializing is the fragile part, and it is fragile in both languages, so it is worth being explicit about what makes it work here:
separators=(",", ":")matchesJSON.stringify, which inserts no spaces.ensure_ascii=Falsematches it too: JavaScript emits real UTF-8 for non-ASCII, Python escapes it to\uXXXXunless told otherwise.- Key order is preserved.
json.loadsfills a dict in document order, Python dicts keep insertion order, and JavaScript's{signature, ...rest}rest spread keeps the order of everything it copies. So both sides walk the keys in the order they arrived on the wire. - Whole numbers stay whole. JavaScript has one number type and never writes
1.0, so a whole number arrives as1, andjson.loadsmakes that anint, whichjson.dumpswrites back as1.
tests/test_webhooks.py checks this against signatures generated by the
actual TypeScript computeSignature, not against a Python reimplementation
agreeing with itself.
WebhookVerificationError ¶
verify_webhook ¶
verify_webhook(body: Union[str, bytes, Dict[str, Any]], secret: str, signature: Optional[str] = None) -> Dict[str, Any]
Check a webhook's signature and return its parsed payload.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Union[str, bytes, Dict[str, Any]]
|
The raw request body, or an already-parsed dict. |
required |
secret
|
str
|
The webhook secret configured for this endpoint. |
required |
signature
|
Optional[str]
|
The |
None
|
Returns:
| Type | Description |
|---|---|
Dict[str, Any]
|
The payload as a dict, with |
Raises:
| Type | Description |
|---|---|
WebhookVerificationError
|
no secret, no signature, unparseable body, or a digest that does not match. |
Compared with :func:hmac.compare_digest, so a wrong signature takes the
same time to reject as a nearly-right one.
Source code in kirokuforms/webhooks.py
compute_signature ¶
The signature KirokuForms would send for this payload.
canonical_payload ¶
The exact string KirokuForms ran the HMAC over, for a parsed payload.
Source code in kirokuforms/webhooks.py
Deprecated¶
kirokuforms.kirokuforms.create_kiroku_interrupt_handler ¶
create_kiroku_interrupt_handler(api_key: str, **kwargs: Any) -> Callable[[Dict[str, Any], Dict[str, Any]], Dict[str, Any]]
Create a blocking human-review step. Deprecated since 0.3.0.
Use :func:kirokuforms.create_kiroku_interrupt_node instead.
This is not a LangGraph interrupt, despite the name. It creates the task and then polls in a loop inside the node, by default for up to an hour, holding a worker open the whole time. The graph is never suspended: nothing is checkpointed, nothing can be resumed in another process, and a reviewer who goes to lunch costs you a live worker until they come back.
create_kiroku_interrupt_node uses LangGraph's own :func:interrupt. The
graph suspends, the state is checkpointed, the process is free, and the run
resumes later from anywhere with Command(resume=...). It also sends an
idempotency key, so the re-execution that happens on every resume finds the
case it already made instead of mailing your reviewer a second copy.
Kept, and still working, because published code imports it. It will not gain features. Migration is usually four lines::
# before
handler = create_kiroku_interrupt_handler(api_key="...")
def node(state):
return handler(state, {"title": "Approve?", "fields": FIELDS})
# after
node = create_kiroku_interrupt_node(
KirokuFormsHITL(api_key="..."), name="approval",
title="Approve?", fields=FIELDS,
)
# then compile the graph with a checkpointer and invoke with a thread_id
Both write their outcome to state["human_verification"], so nothing
downstream of the node has to change.
Source code in kirokuforms/kirokuforms.py
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 | |