Human-in-the-Loop
Agentmd supports pausing an execution mid-run to ask the user a question, then resuming from exactly where it left off — even after a backend restart. This is called a HILT interaction (Human-In-The-Loop).
Concept
When an agent reaches a point that requires human input, the execution pauses (status waiting). It stays paused until you respond. When a response arrives, the execution resumes from the checkpoint where it stopped — the full conversation state is preserved.
Key properties:
- Restart-safe: paused executions survive a backend restart. The LangGraph checkpointer stores state on disk; responding later simply re-loads the checkpoint and continues.
- Non-blocking: waiting for a response does not hold up other agents or the scheduler. A paused execution is fully suspended.
- Non-fatal denial: if you deny a confirmation (or a timeout fires), the guarded tool returns
"Action denied by user"as its result and the agent continues. It is not a hard abort.
The Four Ways a Request Is Raised
1. Default-confirm built-in tools
file_delete and file_write ask for confirmation before running. This is the default behavior — no agent configuration required. The global list of default-guarded tools is configurable in config.yaml (see Global defaults).
▶ Running cleaner google/gemini-2.5-flash
17:03:12 cleaner 🔧 file_delete → {"path": "output/old-report.txt"}
? Confirm file_delete {"path": "output/old-report.txt"} [y/N]:
2. Agent config confirm: and auto_approve:
Use confirm: in the agent frontmatter to guard additional tools by name — built-in, custom, or MCP tools. Use auto_approve: to remove tools from the confirm set (including defaults).
confirm: [file_edit] # also require confirmation for file_edit
auto_approve: [file_write] # skip the file_write confirmation
The effective confirm set is: (global defaults ∪ confirm) − auto_approve
To disable confirmation for all guarded tools at once, set auto_approve: "*" (or "all"):
Note:
auto_approveonly affects guarded-tool confirmation. It does not suppressask_usercalls or SDKrequest_*calls made directly by the agent or a custom tool.
3. The ask_user built-in tool
The agent itself can request input by calling ask_user. This is always available and is not affected by auto_approve.
17:05:44 cleaner 🔧 ask_user → {"question": "Which environment should I target?", "kind": "choice", "options": ["staging", "production"]}
? Which environment should I target?
1. staging
2. production
Choice:
The agent uses ask_user for dynamic questions it can only formulate at runtime — for example, "I found 3 matching files, which one should I delete?"
4. SDK primitives for custom tools
Custom tools can raise any of the three request kinds by calling primitives from agent_md.sdk. See SDK primitives below.
Request Kinds
| Kind | Description | CLI appearance |
|---|---|---|
confirm |
Yes / no approval, with optional reason | [y/N]: prompt |
input |
Free-text entry | Open text prompt |
choice |
Select from a list of options | Numbered menu |
Configuration
Frontmatter fields
Add these to any agent's YAML frontmatter:
confirm
| Property | Value |
|---|---|
| Type | string[] |
| Required | No |
| Default | [] |
Additional tool names to guard with a confirmation step, on top of the global defaults. Accepts built-in tool names, custom tool names, and MCP tool names.
auto_approve
| Property | Value |
|---|---|
| Type | string[] or "*" |
| Required | No |
| Default | [] |
Tool names to remove from the effective confirm set. Cancels the default guard for those tools. Use "*" (or "all") to clear the entire confirm set for this agent.
auto_approve: [file_write] # skip the file_write default confirmation
auto_approve: "*" # never confirm any guarded tool
on_pending
| Property | Value |
|---|---|
| Type | string |
| Allowed values | skip, parallel |
| Default | skip |
Controls whether a new execution can start while a previous one in the same conversation is waiting:
skip(default): a new run is refused while the conversation it belongs to is paused on a pending request. A skipped trigger is logged; a refusedPOST /agents/{id}/runanswers409.parallel: allows multiple concurrent waiting sessions (each paused run is independent — isolated checkpoint threads).
The setting is declared per agent, but it is evaluated per conversation. A conversation is a session_id: one chat session, or — for agentmd run, schedule and watch, which carry no session — the single sessionless bucket they share. So under skip:
| New run | Waiting execution belongs to | Admitted? |
|---|---|---|
| chat in session A | session A | no — this conversation is waiting on you |
| chat in session A | session B | yes |
| chat in session A | sessionless (run / schedule / watch) |
yes |
agentmd run, schedule, watch |
sessionless | no — a previous run is waiting |
agentmd run, schedule, watch |
any chat session | yes |
The point of the narrower scope: a pending confirmation nobody answered used to close the agent to everything, including a chat opened afterwards that had asked nothing. Find it with agentmd pending, then answer it (agentmd respond <id>) or abort it (DELETE /executions/{id}) — or simply start a new chat.
on_pending is not a lock on the agent, and never was: it only refuses while an execution is waiting, so two runs of the same agent can already overlap whenever neither is paused. If a tool must not run twice at once, guard the tool.
confirm_timeout
| Property | Value |
|---|---|
| Type | string |
| Accepted values | 30s, 5m, 2h, 1d, … or none |
| Default | none (wait indefinitely) |
How long to wait for a response before auto-denying. On expiry, the pending request is treated as denied: the guarded tool returns "Action denied by user (timeout)" and the agent continues.
confirm_timeout: 1h # auto-deny if unanswered for 1 hour
confirm_timeout: none # wait forever (default)
Duration grammar: a number followed by s (seconds), m (minutes), h (hours), or d (days).
Complete frontmatter example
---
name: Cleaner
model: { provider: google, name: gemini-2.5-flash }
confirm: [file_edit] # also guard file_edit (file_delete/file_write are guarded by default)
auto_approve: [file_write] # never ask for file_write
on_pending: skip # don't fire again while one session waits
confirm_timeout: 1h # auto-deny if unanswered for 1h (default: none = wait forever)
---
You clean up files. Always ask the user before deleting anything important.
Global defaults
These keys under defaults: in config.yaml apply to all agents and can be overridden per agent in frontmatter:
# config.yaml
defaults:
confirm_tools: [file_delete, file_write] # built-in tools guarded by default (default: [file_delete, file_write])
on_pending: skip # default concurrency mode (default: skip)
confirm_timeout: none # default timeout (default: none)
checkpoint_retention_days: 30 # see Checkpoint storage below
confirm_tools defines the baseline confirm set before any per-agent confirm: or auto_approve: adjustments.
CLI Usage
During agentmd run and agentmd chat
HILT works in both agentmd run (one-shot) and agentmd chat (interactive sessions). When a guarded tool fires or ask_user is called, Agentmd pauses the stream and prompts inline — in a chat session the prompt appears mid-turn, and the agent continues the turn once you answer:
▶ Running cleaner google/gemini-2.5-flash
17:03:12 cleaner 🔧 file_delete → {"path": "output/stale.log"}
? Confirm file_delete {"path": "output/stale.log"} [y/N]: y
17:03:15 cleaner 📎 file_delete ← File deleted: output/stale.log
17:03:16 cleaner ✅ Done.
If you are not watching the terminal (for example, the agent was started in the background or triggered by a scheduler), the execution enters waiting state and you can respond later.
agentmd pending
List all executions currently waiting for a response:
Output:
# Agent Question
──────────────────────────────────────────────────────────────────────
42 cleaner Confirm file_delete {"path": "output/stale.log"}
51 reporter Enter the report title:
agentmd respond <id>
Respond to a waiting execution interactively or with flags:
# Interactive (prompts for the response)
agentmd respond 42
# Approve a confirmation
agentmd respond 42 --yes
# Deny a confirmation with an optional reason
agentmd respond 42 --no --reason "file is still needed"
# Provide text for an input request
agentmd respond 51 --text "Monthly Summary"
# Select a choice
agentmd respond 55 --choice staging
After a successful response, the execution resumes automatically.
Restart Behavior
Paused executions survive a backend restart:
- When
POST .../respondarrives and there is no live resume task (e.g., after a restart), the endpoint re-loads the agent config from the registry, re-attaches to the on-disk checkpoint, and spawns a fresh resume task. - The resume picks up at the exact node where the interrupt fired — no messages are lost.
The history setting and the always-on checkpointer are independent:
- The checkpointer is always active regardless of history. It is the durability substrate for HILT.
- history controls only how much prior context is fed to the LLM at the start of a new, non-HILT run (see Memory).
Limitations
confirm_timeout across restarts. The timeout countdown runs in memory. If the backend restarts while an execution is waiting for a response, the timeout task is not re-armed — the execution will wait indefinitely until it is answered or cancelled. This only affects agents that set an explicit confirm_timeout; the default (none) is unaffected.
Limits are cumulative across pause/resume. max_cost_usd, max_execution_tokens, and max_tool_calls apply to the whole execution, not to each run() / resume() segment in isolation. On resume, the runner seeds tool-call count, token totals, and cost from what was already spent before the pause, then continues enforcing against those running totals. An agent with max_tool_calls: 5 that used 3 tools before pausing has 2 left after resume — not 5 more. The LangGraph recursion limit still caps tool calls within a single resume drive.
SDK Primitives
Custom tools can raise HILT requests using three functions from agent_md.sdk:
request_confirmation(message: str, *, tool_name: str | None = None, tool_args: dict | None = None) -> bool
request_input(message: str) -> str
request_choice(message: str, options: list[str], *, multi: bool = False) -> list[str] | str
All three are thin wrappers over langgraph.types.interrupt() — they pause the execution and return the user's response once it arrives.
request_confirmation
Ask yes/no before proceeding. Returns True if approved, False if denied.
from langchain_core.tools import tool
from agent_md.sdk import request_confirmation, request_choice
@tool
def cleanup(folder: str) -> str:
"""Delete temp files in a folder."""
if not request_confirmation(f"Clean {folder}?", tool_name="cleanup", tool_args={"folder": folder}):
return "User declined."
# ... do the cleanup ...
return "Cleaned."
request_input
Ask for free text. Returns the string the user typed.
from langchain_core.tools import tool
from agent_md.sdk import request_input
@tool
def create_report(topic: str) -> str:
"""Generate a report."""
title = request_input(f"Enter a title for the '{topic}' report:")
# ... generate with title ...
return f"Report '{title}' created."
request_choice
Ask the user to pick from a list. Returns the selected option (or a list if multi=True).
from langchain_core.tools import tool
from agent_md.sdk import request_choice
@tool
def deploy(artifact: str) -> str:
"""Deploy an artifact to an environment."""
env = request_choice("Deploy to which environment?", ["staging", "production"])
# ... deploy to env ...
return f"Deployed {artifact} to {env}."
Note: SDK primitives are always available to custom tools and are not affected by
auto_approve. They are distinct from the guarded-tool mechanism — use them for dynamic, in-code decisions that cannot be expressed as a static "this tool needs approval".
Checkpoint Storage
The LangGraph checkpointer is always on and stores one checkpoint thread per execution in agentmd_checkpoints.db.
Automatic cleanup
A sweep runs on startup (alongside other maintenance tasks). It removes old checkpoint threads, subject to these retention rules:
- The latest execution per agent is always kept (needed to seed
historyon the next run). - Any
waitingexecution is always kept (needed to resume the paused run). - All other finished threads older than
defaults.checkpoint_retention_days(default: 30 days) are eligible for removal.
Configure the retention window in config.yaml:
agentmd checkpoint command
Inspect and manage the checkpoint database manually:
# Show size and thread count, grouped per agent
agentmd checkpoint --stats
# Delete eligible checkpoint threads (respects the keep-set: latest + waiting)
agentmd checkpoint --purge
# Delete eligible threads for a specific agent only
agentmd checkpoint --purge --agent cleaner
# Delete ALL threads, ignoring the keep-set (also removes latest/waiting)
agentmd checkpoint --purge --force
--purge without --force never removes the latest-per-agent thread or any waiting thread, so it is safe to run without disrupting history seeding or a pending resume. Use --force only when you want a complete wipe.
Related Documentation
- Agent Configuration — full frontmatter reference including
confirm,auto_approve,on_pending,confirm_timeout - Custom Tools — building custom tools and using the SDK
- CLI Reference — full command reference
- Memory — session history and how the checkpointer relates to
history