Step-by-step guide

Connect planning to your system of record

Keep meaningful review and approval while choosing where plans are managed.

You will build
An external plan service selected for the workspace.
Time
30 minutes
Before you start
A local JSON-RPC service that stores whole plan documents.

What this changes

A team may want agent plans to live beside the repository or inside an established planning system. Clarvis keeps the planning contract stable so that storage does not weaken review semantics.

  1. 01
    One planning home

    Agent work can participate in the system where the rest of the team already coordinates.

  2. 02
    Approval with meaning

    The reviewed intent remains distinguishable from later changes.

  3. 03
    Less process duplication

    Teams do not need a separate planning practice solely because an agent is involved.

01

Minimum setup

Start with one working configuration.

The runtime keeps plan validation, transitions, and approval semantics. Your provider owns durable reads, listings, reconciliation, deletion, and atomic compare-and-swap writes.

plans_service.py plan provider · Python
# plans_service.py
# A complete plan provider. It answers every method the plan data plane sends,
# and it passes the shared store conformance suite as written.

import json
import sys

PLANS = {}


def send(message):
    sys.stdout.write(json.dumps(message) + "\n")
    sys.stdout.flush()


def result(request_id, value):
    send({"jsonrpc": "2.0", "id": request_id, "result": value})


def failure(request_id, code, message, extra=None):
    data = {"code": code}
    if extra:
        data.update(extra)
    send(
        {
            "jsonrpc": "2.0",
            "id": request_id,
            "error": {"code": -32001, "message": message, "data": data},
        }
    )


def plans_for(owner):
    return PLANS.setdefault(owner, {})


def handle(request):
    request_id = request["id"]
    method = request["method"]
    params = request.get("params") or {}
    owner = params.get("owner", "")

    if method == "initialize":
        result(request_id, {"protocol_version": 1, "provider_kind": "acme-tracker"})
        return
    if method == "shutdown":
        result(request_id, None)
        return

    store = plans_for(owner)

    if method == "plans/create":
        document = params["document"]
        store[document["id"]] = document
        result(request_id, document)
        return

    if method == "plans/read":
        document = store.get(params["id"])
        if document is None:
            failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
            return
        result(request_id, document)
        return

    if method == "plans/list":
        query = params.get("input") or {}
        rows = list(store.values())
        if query.get("status") is not None:
            rows = [row for row in rows if row["status"] == query["status"]]
        if query.get("retention") is not None:
            rows = [row for row in rows if row["retention"] == query["retention"]]
        rows.sort(key=lambda row: row["created_at"], reverse=True)
        offset = int(query.get("cursor") or 0)
        limit = max(1, min(100, int(query.get("limit") or 20)))
        page = rows[offset : offset + limit]
        payload = {"plans": page}
        if offset + len(page) < len(rows):
            payload["next_cursor"] = str(offset + len(page))
        result(request_id, payload)
        return

    if method == "plans/write":
        current = store.get(params["id"])
        if current is None:
            failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
            return
        expected = params["expected"]
        if (
            current["revision"] != expected["revision"]
            or current["digest"] != expected["digest"]
            or current["spec_digest"] != expected["specDigest"]
        ):
            failure(
                request_id,
                "plan_conflict",
                "Plan changed since it was read",
                {"reason": "cas"},
            )
            return
        store[params["id"]] = params["document"]
        result(request_id, params["document"])
        return

    if method == "plans/reconcile":
        document = store.get(params["id"])
        if document is None:
            failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
            return
        result(request_id, document)
        return

    if method == "plans/delete":
        result(request_id, store.pop(params["id"], None) is not None)
        return

    failure(request_id, "plan_invalid", "unknown method %s" % method)


for line in sys.stdin:
    line = line.strip()
    if line:
        handle(json.loads(line))
Why this is the minimum

The command starts without a shell and resolves relative arguments from the workspace root. Select the provider in ~/.clarvis/settings.json: an executable or plugin provider named in a repository's own .clarvis/settings.json is a workspace risk field, so it is stripped from the merge until you approve that workspace with /workspace-trust, and until then planning falls back to the built-in Markdown store without raising an error.

02

Working patterns

Adapt it to work you actually do.

Open a pattern to see the complete files and the reason behind each choice.

Pattern 01 ~/.clarvis/settings.json Configuration

The service above does nothing until a scope selects it. Declare it per user, where the selection takes effect immediately. The command starts without a shell and relative arguments resolve from the workspace root, so plans_service.py is found when you start Clarvis in the checkout that holds it. The same block placed in a repository's own .clarvis/settings.json is withheld instead: an executable or plugin provider is a workspace risk field, stripped from the merge until you approve that workspace with /workspace-trust, and until then planning uses the built-in Markdown store and reports no error. Settings are strict JSON, so this file carries no comments.

~/.clarvis/settings.json example
{
  "plans": {
    "mode": "on",
    "retention": "keep",
    "provider": {
      "kind": "executable",
      "command": "python3",
      "args": ["-B", "plans_service.py"],
      "timeout_ms": 30000
    }
  }
}
Pattern 02 Keep plans in a database instead of a dictionary Configuration

The skeleton loses every plan when the process exits, and it cannot answer a filtered listing without walking everything. A real provider needs durable rows and an index it can query. The port stores the plan as an opaque payload and keeps a small projection beside it precisely so a backend that cannot search inside the document still serves a filtered page.

Keep plans in a database instead of a dictionary example
# The parts that change from the skeleton. Everything else is identical.

import sqlite3

DB = sqlite3.connect("plans.db")
DB.execute(
    """CREATE TABLE IF NOT EXISTS plans (
         owner TEXT NOT NULL,
         id TEXT NOT NULL,
         revision INTEGER NOT NULL,
         digest TEXT NOT NULL,
         spec_digest TEXT NOT NULL,
         status TEXT NOT NULL,
         retention TEXT NOT NULL,
         created_at TEXT NOT NULL,
         document TEXT NOT NULL,
         PRIMARY KEY (owner, id))"""
)
DB.commit()

# plans/write becomes one conditional UPDATE, which is what makes the
# compare and swap atomic instead of a read followed by a write.

expected = params["expected"]
document = params["document"]
cursor = DB.execute(
    "UPDATE plans SET revision = ?, digest = ?, spec_digest = ?, status = ?,"
    " retention = ?, document = ?"
    " WHERE owner = ? AND id = ? AND revision = ? AND digest = ? AND spec_digest = ?",
    (
        document["revision"],
        document["digest"],
        document["spec_digest"],
        document["status"],
        document["retention"],
        json.dumps(document),
        owner,
        params["id"],
        expected["revision"],
        expected["digest"],
        expected["specDigest"],
    ),
)
DB.commit()
if cursor.rowcount == 1:
    result(request_id, document)
elif row_of(owner, params["id"]) is None:
    failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
else:
    failure(request_id, "plan_conflict", "Plan changed since it was read", {"reason": "cas"})

# plans/list filters in SQL and pages with an integer offset cursor.

sql = "SELECT document FROM plans WHERE owner = ?"
args = [owner]
if query.get("status") is not None:
    sql += " AND status = ?"
    args.append(query["status"])
if query.get("retention") is not None:
    sql += " AND retention = ?"
    args.append(query["retention"])
sql += " ORDER BY created_at DESC, id DESC"
rows = [json.loads(r[0]) for r in DB.execute(sql, args).fetchall()]
Pattern 03 Let a change made in your system reach the running agent Configuration

Someone closes a task in your tracker while the agent is mid run. Without a way to publish that back, the agent keeps working a stale copy and you are back to two lists. This is what plans/reconcile is for: the run calls it before every plan mutation and before it reads its own plan, so it is the one place your system gets to say "this moved".

Let a change made in your system reach the running agent example
# In plans/reconcile, return the current document. When your own system
# changed it, advance the counters and change the digest so the caller
# treats it as a new revision rather than as the one it already held.

if method == "plans/reconcile":
    document = store.get(params["id"])
    if document is None:
        failure(request_id, "plan_not_found", "Plan not found: %s" % params["id"])
        return
    updated = apply_tracker_state(document)   # your mapping, your business
    if updated is not document:
        updated["revision"] = document["revision"] + 1
        updated["digest"] = new_opaque_token()
        # bump spec_revision too, but only when the plan's substance moved:
        # its objective, context, validation, or a task's title/detail/exit.
        store[params["id"]] = updated
    result(request_id, updated)
    return

# The run adopts what you returned as its new baseline, so the next
# mutation it sends carries your revision and digest and passes your
# compare and swap check. A conflict raised in the meantime is not fatal:
# the run re-reads and continues.
03

Verify the behavior

Check the boundary, not just the happy path.

Use a small disposable task first. Confirm what works and what is deliberately unavailable before relying on the configuration in a real workflow.

  1. 01

    The provider initializes with the expected protocol version before plan work begins.

  2. 02

    A write checks revision, digest, and spec digest atomically before replacing the stored document.

  3. 03

    A change made in the external system reaches the agent through reconciliation instead of creating a second plan.

Next guide

Return to the agent team

Use the complete configuration journey to refine roles against a real workflow.