Skip to content

Managing Coding Agents in Threads

Managing agent sessions across different contexts is a headache. I often find myself starting a task in one place, losing the state in another, and ending up with fragmented outputs or duplicate runs. It is frustrating when you cannot keep a conversation locked to a specific thread where the context lives.

I want to show you how we handle ACP (Agent Control Plane) coding agents in thread-capable channels. This approach ensures that your agent stays bound to a specific thread, making the lifecycle and recovery much easier to manage.

  • OpenClaw core (handles the control plane and routing)
  • Discord channel (for the thread-capable interface)
  • acpx-backed plugin service (the initial runtime backend)
  • SQLite store (for state persistence)

I recommend following these steps to get an ACP session running in a thread. The goal is to move from a generic session to a bound thread persona in under five minutes.

  1. Spawn or Focus: Use the /focus command to target an ACP session into a specific thread.
  2. Route Messages: Once bound, any message you send in that thread routes directly to the bound ACP session.
  3. Stream Output: The agent output streams back to the same thread persona, keeping the history in one place.
  4. Manage Lifecycle: Use explicit cleanup controls or the /unfocus command when you finish the session.

I prefer this hybrid architecture because OpenClaw core owns the session identity and routing decisions, while the pluggable runtime handles the heavy lifting of transport and queueing.

I believe the best way to maintain reliability is through explicit states. Every ACP session in this system follows a specific lifecycle:

  • creating
  • idle
  • running
  • cancelling
  • closed
  • error

By using a SQLite store in WAL mode, the system keeps a record of every event. If the system crashes, we can use these append-only logs for replay and recovery. This ensures that delivery remains deterministic and you do not lose your work.

If you run into issues, check these common scenarios based on the current implementation:

  • Metadata Gaps: If messages are not routing correctly, I recommend checking the core ACP branch. We fixed metadata-gap bugs by ensuring all ACP turns go through a single core routing path.
  • Duplicate Outputs: If you see double messages in Discord, ensure your idempotency keys are active. We use these keys and fail-closed routing checks to prevent duplicate runs.
  • Routing Failures: Remember that inbound routing from a thread binding must resolve to a session key in the core dispatch first. Plugin hooks alone cannot replace this core dispatch flow.

If you need help with your specific configuration, check the AI Setup Assistant.

I’ve spent a lot of time dealing with the headache of managing long-running processes in bot architectures. It is frustrating when a simple server restart wipes out active sessions or when you end up with duplicate messages because a process didn’t know it was already running. I wanted to walk you through how we handle this in OpenClaw using the Agent Control Plane (ACP).

  • OpenClaw core
  • acpx plugin backend
  • SQLite (with WAL mode enabled)

To get moving with ACP, you need to understand the boundary between the core and the plugins. The core handles the heavy lifting of session-mode dispatch and delivery arbitration, while the acpx plugin handles the actual process execution.

We use a core ACP runtime contract so that our routing code doesn’t care about CLI details. This allows us to switch backends without touching the dispatch logic. Here is the interface I use:

export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
};
export type AcpRuntimeEvent =
| { type: "text_delta"; stream: "output" | "thought"; text: string }
| { type: "tool_call"; name: string; argumentsText: string }
| { type: "done"; usage?: Record<string, number> }
| { type: "error"; code: string; message: string; retryable?: boolean };
export interface AcpRuntime {
ensureSession(input: {
sessionKey: string;
agent: string;
mode: "persistent" | "oneshot";
cwd?: string;
env?: Record<string, string>;
idempotencyKey: string;
}): Promise<AcpRuntimeHandle>;
submit(input: {
handle: AcpRuntimeHandle;
text: string;
mode: AcpRuntimePromptMode;
idempotencyKey: string;
}): Promise<{ runtimeRunId: string }>;
stream(input: {
handle: AcpRuntimeHandle;
runtimeRunId: string;
onEvent: (event: AcpRuntimeEvent) => Promise<void> | void;
signal?: AbortSignal;
}): Promise<void>;
cancel(input: {
handle: AcpRuntimeHandle;
runtimeRunId?: string;
reason?: string;
idempotencyKey: string;
}): Promise<void>;
close(input: { handle: AcpRuntimeHandle; reason: string; idempotencyKey: string }): Promise<void>;
health?(): Promise<{ ok: boolean; details?: string }>;
}

I rely on a dedicated ACP SQLite database in WAL mode as the source of truth. This makes the system crash-safe. If the gateway dies, it rehydrates from this DB and resumes from checkpoints.

The core metadata looks like this:

export type AcpSessionMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
mode: "persistent" | "oneshot";
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};

The database tracks sessions, runs, bindings, events, and delivery checkpoints. I make sure process IDs and sockets stay in memory only, while the durable lifecycle lives in the DB.

When a message comes in, I look up the thread binding. If it’s an ACP session, it goes to the ACP runtime branch. If you use /acp steer, it triggers the steer mode.

  • creating -> idle -> running -> idle
  • running -> cancelling -> idle | error
  • idle -> closed
  • error -> idle | closed
  • queued -> running -> completed
  • running -> failed | cancelled
  • queued -> cancelled

I use an actor model via AcpSessionManager. Each session key gets one actor that serializes all side effects like submit or cancel. This prevents out-of-order output and race conditions.

Every external action requires an idempotency key. This covers spawning, prompting, cancelling, and closing. Discord messages are derived from the acp_events table and the acp_delivery_checkpoint. If a connection drops, retries resume from the last checkpoint so I don’t send duplicate chunks.

If things go wrong, here are the standard behaviors and solutions:

  • Stale Bindings: If a binding exists but the ACP session is missing, the system fails closed. You will see an explicit stale-binding message. I usually validate these before auto-unbinding.
  • Gateway Restarts: On start, the system loads all non-terminal sessions. It reconciles runs that are marked as running but are missing heartbeats, marking them as failed or recovering them.
  • Runtime Crashes: I use bounded restart backoffs to prevent infinite crash loops.

You have a few ways to manage active sessions:

  1. Cancel a run with /acp cancel.
  2. Unfocus a thread with /unfocus.
  3. Close a session with /acp close.
  4. Let the auto-close TTL handle idle sessions.

I recommend using the allowlist for ACP agents and restricting workspace roots to keep your environment safe.

For more help, check out the AI Setup Assistant.

I have spent many late nights staring at configuration files, trying to figure out which toggle actually fixes the issue. It is often the case that you just want a clear map of the available knobs and dials so you can get back to building.

Configuring the ACP surface does not have to be a guessing game. I prefer to keep things simple by knowing exactly which keys control the routing, the backend, and the recovery logic.

  • Access to your main configuration file.
  • A list of the agent names you plan to use.

If you want to get up and running in a few minutes, follow these four steps:

  1. Set acp.enabled to true to turn on the system.
  2. Specify your acp.backend. I recommend using the default acpx.
  3. Add your authorized agents to the acp.allowedAgents[] list.
  4. Define a acp.defaultAgent to handle incoming sessions.

These are the primary keys you will use to manage the ACP lifecycle and behavior.

  • acp.enabled: The main switch for the ACP system.
  • acp.dispatch.enabled: This is an independent routing kill switch. Use this if you need to stop traffic without shutting down the entire ACP.
  • acp.backend: The backend type. This defaults to acpx.
  • acp.defaultAgent: The agent used when no specific agent is requested.
  • acp.allowedAgents[]: An array of agents permitted to run in this environment.
  • acp.maxConcurrentSessions: Limits how many sessions can run at once.
  • acp.stream.coalesceIdleMs: Time to wait for idle streams to group data.
  • acp.stream.maxChunkChars: The maximum character count for a single stream chunk.
  • acp.runtime.ttlMinutes: How long the runtime stays active before timing out.
  • acp.idempotency.ttlHours: How long idempotency keys are stored.
  • acp.controlPlane.store: The storage backend, which defaults to sqlite.
  • acp.controlPlane.storePath: The file path for your storage database.
  • acp.controlPlane.recovery.eagerActors: Controls how aggressively the system recovers actors.
  • acp.controlPlane.recovery.reconcileRunningAfterMs: The delay before reconciling running processes during recovery.
  • acp.controlPlane.checkpoint.flushEveryEvents: Flush checkpoints based on event count.
  • acp.controlPlane.checkpoint.flushEveryMs: Flush checkpoints based on a time interval.
  • channels.discord.threadBindings.spawnAcpSessions: Specific setting for spawning sessions within Discord threads.

When you use the acpx plugin, you have access to specific backend overrides. These allow you to fine-tune the execution environment:

  • Command/Path Overrides: Change the default backend binary or script paths.
  • Env Allowlist: Define which environment variables are passed to the backend.
  • Per-agent Presets: Create specific configurations for different agents.
  • Startup/Stop Timeouts: Set how long the system waits for a backend to start or exit.
  • Max Inflight Runs per Session: Limit concurrent executions within a single session.

If you run into trouble, check these two areas first:

  • Routing Issues: If sessions aren’t being directed correctly, check acp.dispatch.enabled. If this is false, all routing stops even if the ACP is enabled.
  • Persistence Failures: If your state isn’t saving, verify the acp.controlPlane.storePath. Since the default acp.controlPlane.store is sqlite, the process needs write access to that path.

If you need more help with your specific config, ask the AI Setup Assistant.

We’ve all been there: you’re trying to manage long-running agent sessions, but the state is scattered across three different databases, and if a process restarts, everything just vanishes. It’s frustrating when you can’t tell if a session is actually running or if it just died quietly in the background without leaving a log.

I’ve been working through the implementation spec for our new Agent Control Plane (ACP), and it’s designed specifically to fix that. We’re moving away from ad-hoc metadata writes and toward a centralized, durable control plane that treats agent sessions as first-class citizens with strict invariants.

  • Access to the core repository (specifically src/acp/)
  • The acpx runtime plugin installed and enabled
  • acp.enabled set to true in your global config
  • acp.dispatch.enabled set to true (if you’re ready for the dispatch path)

If you want to get a backend up and running in 5 minutes, here is the path. First, you need to register your backend with the core registry:

import { registerAcpRuntimeBackend } from 'src/acp/runtime/registry';
registerAcpRuntimeBackend({
id: 'acpx',
runtime: myRuntimeInstance,
healthy: () => true
});

Next, ensure your configuration enables the dispatch gate. In your config file, set acp.dispatch.enabled: true. This allows the AcpSessionManager to start routing commands through the new control plane instead of falling back to legacy paths.

I’ve organized the control plane into several dedicated modules in src/acp/control-plane/ to keep concerns separated:

  • manager.ts: This is the brain. It owns ACP actors, lifecycle transitions, and command serialization.
  • store.ts: Handles SQLite schema management and transactions.
  • events.ts: Contains typed event definitions.
  • checkpoint.ts: Manages durable delivery checkpoints so we don’t lose messages.
  • idempotency.ts: Uses keys to prevent duplicate command execution.
  • recovery.ts: Handles boot-time reconciliation to rehydrate actors after a crash.

We also have a compatibility bridge in src/acp/runtime/session-meta.ts. It stays for now to project data into SessionEntry.acp, but it won’t be the source of truth once we finish the migration.

To keep the system stable, I’ve baked these rules directly into the code:

  1. ACP session creation and thread binding are atomic (one single transaction).
  2. Only one active run per ACP session actor is allowed at a time.
  3. Event seq numbers must strictly increase per run.
  4. Delivery checkpoints never advance past the last committed event.

The core registry module at src/acp/runtime/registry.ts is how plugins talk to the control plane. Here is the API you’ll be using:

export type AcpRuntimeBackend = {
id: string;
runtime: AcpRuntime;
healthy?: () => boolean;
};
export function registerAcpRuntimeBackend(backend: AcpRuntimeBackend): void;
export function unregisterAcpRuntimeBackend(id: string): void;
export function getAcpRuntimeBackend(id?: string): AcpRuntimeBackend | null;
export function requireAcpRuntimeBackend(id?: string): AcpRuntimeBackend;

If you call requireAcpRuntimeBackend and the backend isn’t there, it throws a typed error. Plugins should register on start and unregister on stop.

For the acpx backend, we use a strict CLI command contract. The runtime handle is encoded as runtimeSessionName = acpx:v1:<base64url(json)>.

When the manager needs to talk to the backend, it maps commands like this:

  • Ensure Session: acpx --format json --json-strict --cwd <cwd> <agent> sessions ensure --name <name>
  • Prompt Turn: acpx --format json --json-strict --cwd <cwd> <agent> prompt --session <name> --file -
  • Cancel: acpx --format json --json-strict --cwd <cwd> <agent> cancel --session <name>
  • Close: acpx --format json --json-strict --cwd <cwd> <agent> sessions close <name>

We consume ndjson events from the stdout. For example, thought maps to text_delta/thought, and done signals the end of the stream.

I’ve patched SessionEntry in src/config/sessions/types.ts to include the new metadata:

type SessionAcpMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
mode: "persistent" | "oneshot";
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};

We are rolling this out in four phases. We start with dual-writing to both the legacy acp projection and the new SQLite source-of-truth. Eventually, we’ll move to reading primarily from SQLite and backfilling missing rows from legacy entries.

We have a strict rule for routing: if an active thread binding exists for an ACP session, we deliver only to that thread. We don’t double-post to the parent channel. If the destination is ambiguous, the system fails closed with an error rather than guessing where to send the message.

If things go wrong, look for these specific error codes in your logs:

  • ACP_BACKEND_MISSING: The runtime backend isn’t configured. You probably need to enable the acpx plugin.
  • ACP_BACKEND_UNAVAILABLE: The backend is registered but currently failing health checks.
  • ACP_SESSION_INIT_FAILED: We couldn’t initialize the session runtime.
  • ACP_TURN_FAILED: The turn failed before it could complete.

You can also run /acp doctor to validate your store health and backend registration, or /acp sessions to see the state of active runs and errors.

I’ve added a kill switch: acp.dispatch.enabled. It defaults to false for the first release. While it’s off, you can still bind sessions using focus commands, but the main dispatch path won’t activate. This lets us validate the logic before we let it handle all traffic.

Need help getting your backend registered? Check out the AI Setup Assistant.

I have spent too much time chasing rogue agent processes or wondering why a session didn’t resume after a system restart. It is a common headache when building complex agentic systems: you want the agent to stay focused, but the state management usually feels like a house of cards.

I want to walk you through our new Command and UX plan for the Agent Control Plane (ACP). We are moving toward a more predictable, transactional way to handle agent sessions so you can stop worrying about state drift and start building.

  • An active Discord integration (our first supported channel for event-driven projection).
  • The acpx backend adapter configured in your runtime.
  • Access to the ACP SQLite store for session persistence.

You can get an agent up and running in under five minutes using the new command suite. Here is the path to a minimal setup:

  1. Spawn the agent: Use the spawn command to start a session.
    Terminal window
    /acp spawn <agent-id> [--mode persistent|oneshot] [--thread auto|here|off]
  2. Interact and Steer: If the agent needs a nudge or a change in direction, use steer.
    Terminal window
    /acp steer <instruction>
  3. Monitor Sessions: Check what is currently active.
    Terminal window
    /acp sessions
  4. Clean Up: Close the session when you are finished.
    Terminal window
    /acp close [session]

If you prefer the existing workflow, /focus <sessionKey> still supports ACP targets, and /unfocus keeps its current behavior. We have also replaced the old TTL overrides with /session idle and /session max-age.

I am following a strict six-phase plan to ensure the system is stable before we flip the switch on production traffic.

We started by freezing the DB schema. This includes acp_sessions, acp_runs, and acp_bindings. I implemented the AcpSessionManager and a per-session actor runtime to handle the core logic. Everything here is backed by SQLite with transactional guarantees.

We route thread-bound ACP turns through a dispatch pipeline. If a binding or session invariant fails, we enforce fail-closed routing. The acpx adapter handles the runtime contract:

  • ensureSession
  • submit
  • stream
  • cancel
  • close

We are implementing event-driven channel projection (starting with Discord). This ensures we can resume from checkpoints and provide exactly-once delivery for final messages. We use a dual-write approach during migration, writing to both the legacy projection and the new ACP SQLite source-of-truth.

The final step involves enforcing concurrency limits and scale budgets. I am adding full telemetry and crash recovery testing to make sure the system handles backend outages gracefully.

To keep the codebase maintainable, I have already completed several targeted refactors:

  • Policy Centralization: Shared helpers now live in src/acp/policy.ts.
  • Command Splitting: src/auto-reply/reply/commands-acp.ts is now a thin router, with logic split into lifecycle.ts, runtime-options.ts, and diagnostics.ts.
  • Manager Responsibility: The session manager is split into manager.core.ts, manager.types.ts, and manager.utils.ts.

Here are some common issues you might run into and how we handle them in the ACP:

  • Duplicate Deliveries: We use a single destination resolver and an idempotent event checkpoint to prevent double-posting.
  • Missing Backend: If a plugin is misconfigured, the system provides an explicit operator-facing error and fails closed rather than falling back to a normal session.
  • Stale Bindings: The system includes a detector for stale bindings. You will see an explicit error, and you can enable an optional auto-clean policy.
  • Process Churn: To handle load, we use long-lived per-session owners combined with concurrency caps and backoff policies.

If you hit a wall, you can always use AI Setup Assistant for real-time help.

OpenClaw

OpenClaw Expert

Still stuck?

If this page didn't answer your case, ask OpenClaw Expert for step-by-step guidance.