How to Implement Pause and Resume Functionality in Agent Systems
Implement pause and resume functionality by externalizing agent state to a durable ThreadStore and passing a unique thread_id through webhook payloads, enabling stateless orchestration that survives process restarts and scaling events.
The 12-factor-agents framework from humanlayer treats an agent as a deterministic program that can be launched, paused, and resumed through simple HTTP APIs. According to the source code in packages/create-12-factor-agent/template/src, this pattern relies on durable thread persistence and stateless webhook handlers to create interruptible AI workflows. By externalizing state from the execution context, developers can build multiplayer agents that pause for human input and resume hours later without data loss.
Core Architecture of Pause and Resume
Durable Thread Persistence (state.ts)
In packages/create-12-factor-agent/template/src/state.ts, the ThreadStore interface defines the contract for saving execution traces. The reference implementation uses FileSystemThreadStore to serialize threads as JSON, but the abstraction allows swapping in Redis, PostgreSQL, or SQLite without changing orchestration logic.
Stateless HTTP Orchestration (server.ts)
The outerLoop handler in packages/create-12-factor-agent/template/src/server.ts exposes a single POST /api/v1/conversations endpoint. This endpoint receives events from the Humanlayer platform and uses the thread_id from the webhook payload to retrieve persisted state via store.get(threadId).
Deterministic Inner Loop (agent.ts)
The agentLoop function in packages/create-12-factor-agent/template/src/agent.ts runs deterministic LLM reasoning through b.DetermineNextStep(). When the loop encounters an intent requiring external validation—such as "request_more_information" or a sensitive tool like divide—it returns control to the outer loop without mutating external resources.
The Pause and Resume Lifecycle
Step 1: Launch
A client sends a conversation.created event to POST /api/v1/conversations. The server creates a new Thread, persists it via store.create(), and immediately invokes the inner loop.
Step 2: Inner Loop Execution
The deterministic loop processes events until it reaches a decision point. If the next step requires human approval, the loop yields.
Step 3: Pause via Human Contact
When human input is required, the server calls hl.createHumanContact() with a payload containing state: { thread_id: threadId }. The server responds with HTTP 200 and awaits a webhook, effectively pausing execution.
Step 4: Resume via Webhook
Humanlayer sends a human_contact.completed webhook containing the same thread_id. The handler extracts this ID, loads the thread with store.get(threadId), appends the human response, and re-enters the inner loop.
Step 5: Completion
When the inner loop reaches a terminal intent like done_for_now, the final state is persisted and the agent shuts down cleanly.
Code Implementation Examples
Starting a Conversation (Launch)
// Client request to launch the agent
await fetch('https://my-agent.example.com/api/v1/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'conversation.created',
event: {
user_message: 'What is 7 × 8?',
contact_channel_id: 42,
agent_name: 'calculator'
}
})
});
Creating a Pause Point
// server.ts - Triggering a pause when human input needed
if (['request_more_information', 'done_for_now'].includes(lastEvent.data.intent)) {
hl.createHumanContact({
spec: {
msg: lastEvent.data.message,
state: { thread_id: threadId } // Critical: passes thread ID to webhook
}
});
}
Resuming After Human Response
// server.ts - Handling human_contact.completed webhook
case "human_contact.completed":
const threadId = body.event.spec.state?.thread_id;
const thread = await store.get(threadId);
// Append the human response to the thread
thread.events.push({
type: "human_response",
data: { msg: body.event.status.response }
});
// Resume execution
const newThread = await innerLoop(thread);
await store.update(threadId, newThread);
Implementing a Custom ThreadStore
// Example: Redis implementation of ThreadStore interface
class RedisThreadStore implements ThreadStore {
async get(threadId: string): Promise<Thread> {
// Redis fetch logic
}
async update(threadId: string, thread: Thread): Promise<void> {
// Redis write logic
}
}
Key Design Principles
- Externalized State: The
thread_idis the only state required to resume; the orchestration layer is stateless. - Deterministic Execution: The inner loop in
agent.tsnever mutates external resources directly; it only updates the in-memory thread object until explicitly persisted viastore.update(). - Unified Event Schema: All webhooks (
conversation.created,human_contact.completed,function_call.completed) share the same structure, simplifying routing inserver.ts. - Storage Agnostic: The
ThreadStoreinterface instate.tsdecouples business logic from persistence implementation.
Summary
- Implement pause and resume by externalizing agent threads to a durable
ThreadStoreinterface, as defined inpackages/create-12-factor-agent/template/src/state.ts. - Use a
thread_idfield in webhook payloads to maintain continuity across asynchronous human interactions. - Handle all lifecycle events through a single HTTP endpoint (
POST /api/v1/conversations) that routes based on event type. - Ensure the inner loop is deterministic and only mutates external resources after explicit persistence calls.
- Support horizontal scaling and process restarts by keeping the orchestration layer stateless.
Frequently Asked Questions
What makes an agent "resumable" in the 12-factor model?
An agent is resumable when its complete execution context is serialized to durable storage via the ThreadStore interface and can be retrieved using a unique identifier passed through webhook payloads. This eliminates dependency on in-memory state between requests, allowing the agent to survive process restarts.
How does the thread_id maintain state across asynchronous webhooks?
The thread_id travels in the state field of every Humanlayer request (e.g., hl.createHumanContact({ spec: { state: { thread_id: threadId } } })). When the webhook returns, the server extracts this ID to load the exact execution context via store.get(threadId), enabling seamless resumption at the precise pause point.
Can I use a database other than the filesystem for persistence?
Yes. The ThreadStore interface in packages/create-12-factor-agent/template/src/state.ts abstracts persistence logic. You can implement this interface for Redis, PostgreSQL, or DynamoDB without modifying the pause/resume logic in server.ts or agent.ts, as the outer loop only depends on the interface methods create, get, and update.
How does this pattern differ from traditional stateful agent architectures?
Traditional architectures maintain agent state in long-running processes or in-memory caches, making them fragile to restarts and difficult to scale horizontally. The 12-factor approach externalizes all state to a ThreadStore, making the HTTP handlers stateless and allowing any process instance to resume the agent by loading the thread from storage.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →