Docs · Custom agents

Connect your own agent over HTTP.

Your agent reports its runs to Sulcus, asks a person to approve an action, and checks whether someone asked it to stop. It does all of this by calling a plain HTTP API, and Sulcus can’t stop or block an agent that ignores it.

Preview

What it is

Any agent, over plain HTTP.

There’s no SDK and nothing to install: the API is a few JSON requests, with curl, Python and JavaScript examples below.

Any agent, with or without a framework, can report its runs to Sulcus. Each run appears under its project’s runs, with its timeline and token use. Your agent can ask a person in Sulcus to approve an action before it acts, and learns when someone presses Request stop. Use it for an agent Sulcus doesn’t host and has no integration for, in any language that can send JSON over HTTPS.

Your agent has to cooperate.

Your agent starts its runs, sends its own events, asks before an action and checks stop_requested. Sulcus sees only what your agent reports. It can’t stop your agent, and it can’t block an action your agent takes without asking.

Connect

Connect in five steps.

  1. Create the project

    In Sulcus, open Integrations and choose Custom agent. It creates a project and its token.

  2. Copy the token

    It’s shown once, with examples that use it. Sulcus keeps only its SHA-256 hash, so it can’t show it again.

  3. Set it in your shell

    Your agent reads it from SULCUS_AGENT_TOKEN. Set it in the shell you run the agent from.

  4. Run your agent

    Start from one of the examples. The page says when your agent’s first request arrives.

  5. Manage the token

    On the project’s page, under Custom agents, create, rotate or revoke the token and see the project’s agents.

Token

Set the token where your agent runs.

The examples read the token from SULCUS_AGENT_TOKEN. Set it in the shell you run your agent from.

In Command Prompt, leave out the quotes: Command Prompt keeps them as part of the value.

macOS / Linux
export SULCUS_AGENT_TOKEN=slc_...
PowerShell
$env:SULCUS_AGENT_TOKEN = "slc_..."
Command Prompt · no quotes
set SULCUS_AGENT_TOKEN=slc_...

Authentication

One token per project.

The agents of one project share one token, and every request sends it. Every path under /api/agent/v1/ requires it, including paths that don’t exist. A missing or wrong token is a 401. Sign-in tokens and other integrations’ tokens are refused.

A run of another project is a 404, the same as a run that doesn’t exist. All requests and responses are JSON.

Every request
Authorization: Bearer slc_...
Run state · start, heartbeat and finish return it
{"run_id": "…", "agent_id": "agent_…", "status": "running", "stop_requested": false}

Endpoints

Seven requests.

Paths are under https://app.sulcus.dev/api/agent/v1. Every request is safe to retry as is: a repeated run_key, event sequence, tool_call_id or finish returns what the first attempt did.

RequestWhat it doesReturns
POST /runsStarts a run. The first run with a new agent_key creates the agent.The run’s state: 201, or 200 for a run_key already used
POST /runs/{run_id}/eventsReports up to 50 events.accepted and stop_requested
POST /runs/{run_id}/heartbeatNo body. Keeps a quiet run open; send one at least every few minutes.The run’s state
POST /runs/{run_id}/finishEnds the run as completed, failed or stopped.The run’s state
POST /runs/{run_id}/approvalsAsks a person in Sulcus to approve an action.The approval: 201, or 200 for a tool_call_id already used
GET /runs/{run_id}/approvals/{approval_id}Reads the decision. Poll every 2 seconds or so.The approval
POST /runs/{run_id}/approvals/{approval_id}/expireStops waiting, with the reason timeout or abandoned.The approval, with a person’s decision if it came first

Start a run

Start a run.

agent_key is required: your stable name for the agent, 1–100 characters from A-Z a-z 0-9 . _ : / -. The first run with a new key creates the agent. agent_name is an optional display name of up to 100 characters, and name an optional run name of up to 120.

run_key makes starting safe to retry: the same key returns the same run with 200 instead of 201, even after the run ended. A key belongs to the agent that used it first; another agent’s use is a 409.

A run starts as running. A project has at most 100 open runs; starting another is a 409 until one ends.

Request · POST /runs
curl -s https://app.sulcus.dev/api/agent/v1/runs \
  -H "Authorization: Bearer $SULCUS_AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{"agent_key": "billing-bot", "agent_name": "Billing bot", "run_key": "ticket-4711"}'
Response · 201
{"run_id": "…", "agent_id": "agent_…", "status": "running", "stop_requested": false}

Report events

Report what your agent does.

Send up to 50 events per batch. sequence is an integer that increases across the run’s events. A sequence Sulcus already stored is skipped, so a batch can be retried as is; accepted counts only new events.

timestamp is ISO 8601 with a time zone, at most 24 hours old and at most 5 minutes in the future. level is DEBUG, INFO (the default), WARNING, ERROR or CRITICAL. Only the metadata keys listed below are kept.

For a timeline grouped like Sulcus’s own integrations, use their event types: execution.started, completed, failed and cancelled; agent.started, completed and failed; llm.requested and completed; tool.execution_started, completed and failed. A runtime.tokens.used event with a cumulative run_total_tokens sets the run’s token totals.

A batch that is refused, for any reason, stores nothing. Events for a run that has ended are a 409. tool_approval_* types are refused with 422: ask for approval instead.

Request · POST /runs/$RUN_ID/events
curl -s https://app.sulcus.dev/api/agent/v1/runs/$RUN_ID/events \
  -H "Authorization: Bearer $SULCUS_AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{"events": [
        {"sequence": 0, "event_type": "agent.started", "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S+00:00)"'",
         "metadata": {"agent_id": "planner", "agent_name": "Planner"}},
        {"sequence": 1, "event_type": "runtime.tokens.used", "timestamp": "'"$(date -u +%Y-%m-%dT%H:%M:%S+00:00)"'",
         "metadata": {"model": "gpt-4.1", "input_tokens": 812, "output_tokens": 96,
                      "run_input_tokens": 812, "run_output_tokens": 96, "run_total_tokens": 908}}
      ]}'
Response
{"accepted": 2, "stop_requested": false}

Heartbeat and finish

Keep the run open, then finish it.

While a run is open and has nothing else to report, send POST /runs/{run_id}/heartbeat, with no body, at least every few minutes. Polling an approval counts too. A run with no events, heartbeats or approval polls for 10 minutes ends as failed (“lost contact”), or as stopped if a stop was requested.

Finish with completed, failed or stopped. Finishing a run that has already ended changes nothing and returns its state. After a stop was requested, completed is recorded as stopped.

Request · POST /runs/$RUN_ID/finish
curl -s https://app.sulcus.dev/api/agent/v1/runs/$RUN_ID/finish \
  -H "Authorization: Bearer $SULCUS_AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{"status": "completed"}'

Approvals

Ask before an action.

Send a tool_call_id, your id for this action in the run, and a tool_name, the kind of action shown to the person deciding. The run shows waiting for approval, and the request appears with the dashboard’s other approvals. Workspace members who turned on approval emails get one after 45 seconds, naming the action, project and agent, never what the action would do.

Then poll GET /runs/{run_id}/approvals/{approval_id} every 2 seconds or so until status is no longer pending: it becomes approved, denied or expired. Act only on approved. Reading never consumes the decision.

Sulcus never decides on its own and has no deadline of its own: your agent decides how long to wait. Sulcus’s own integrations wait 10 minutes. To give up, send POST /runs/{run_id}/approvals/{approval_id}/expire with timeout or abandoned. If a person decided first, the response carries their decision.

Asking again with the same tool_call_id returns the same approval. A request is a 409 on a run that has ended or was asked to stop, and when 20 approvals of the run are already waiting. An id or name that looks like a credential, such as sk-… or slc_…, is a 422. An approval also expires when its run ends, goes silent or is asked to stop.

Request · POST /runs/$RUN_ID/approvals
curl -s https://app.sulcus.dev/api/agent/v1/runs/$RUN_ID/approvals \
  -H "Authorization: Bearer $SULCUS_AGENT_TOKEN" -H "Content-Type: application/json" \
  -d '{"tool_call_id": "deploy-1", "tool_name": "deploy",
       "context": {"command": "kubectl apply -f deploy.yaml", "cwd": "/srv/app"}}'
Response · 201
{"approval_id": "approval_…", "status": "pending", "actionable": true, "stop_requested": false}
Stop waiting · POST …/expire
{"reason": "timeout"}

Example · Python

A whole agent in Python.

Python 3.9 or later, standard library only. It reports two steps, asks before deploying, deploys only if a person approved, and stops as soon as Sulcus says a stop was requested. Replace work with your agent’s.

agent.py
import json
import os
import time
import urllib.request
from datetime import datetime, timezone

BASE = os.environ.get("SULCUS_URL", "https://app.sulcus.dev") + "/api/agent/v1"
TOKEN = os.environ["SULCUS_AGENT_TOKEN"]


def call(method, path, body=None):
    request = urllib.request.Request(
        BASE + path, method=method, data=None if body is None else json.dumps(body).encode(),
        headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)


def work(step):
    time.sleep(1)  # your agent's work


class Run:
    def __init__(self, agent_key, agent_name):
        self.id = call("POST", "/runs", {"agent_key": agent_key, "agent_name": agent_name})["run_id"]
        self.sequence = 0

    def report(self, event_type, **metadata):
        """Report one event; True when a stop was requested."""
        event = {"sequence": self.sequence, "event_type": event_type,
                 "timestamp": datetime.now(timezone.utc).isoformat(), "metadata": metadata}
        self.sequence += 1
        return call("POST", f"/runs/{self.id}/events", {"events": [event]})["stop_requested"]

    def ask(self, tool_call_id, tool_name, context=None, timeout=600):
        """Wait for a person's decision in Sulcus; returns the approval as last read."""
        approval = call("POST", f"/runs/{self.id}/approvals",
                        {"tool_call_id": tool_call_id, "tool_name": tool_name, "context": context})
        path = f"/runs/{self.id}/approvals/{approval['approval_id']}"
        deadline = time.monotonic() + timeout
        while approval["status"] == "pending":
            if time.monotonic() > deadline:
                # A decision that arrived first is returned instead.
                return call("POST", path + "/expire", {"reason": "timeout"})
            time.sleep(2)
            approval = call("GET", path)
        return approval

    def finish(self, status):
        return call("POST", f"/runs/{self.id}/finish", {"status": status})


def main():
    run = Run("deploy-bot", "Deploy bot")
    for step in ("build", "test"):
        if run.report("tool.execution_started", tool_call_id=step, tool_name=step):
            return run.finish("stopped")
        work(step)
        run.report("tool.execution_completed", tool_call_id=step, tool_name=step)
    approval = run.ask("deploy-1", "deploy", {"command": "kubectl apply -f deploy.yaml", "cwd": "/srv/app"})
    if approval["stop_requested"]:
        return run.finish("stopped")
    if approval["status"] != "approved":
        return run.finish("failed")  # denied, or nobody decided in time
    work("deploy")
    run.report("tool.execution_completed", tool_call_id="deploy-1", tool_name="deploy")
    return run.finish("completed")


if __name__ == "__main__":
    print(main()["status"])

Example · JavaScript

The same agent in JavaScript.

Node.js 18 or later, no packages. Save it as agent.mjs. Both examples read the token from SULCUS_AGENT_TOKEN.

agent.mjs
const BASE = (process.env.SULCUS_URL ?? "https://app.sulcus.dev") + "/api/agent/v1";
const headers = {Authorization: `Bearer ${process.env.SULCUS_AGENT_TOKEN}`, "Content-Type": "application/json"};
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

async function call(method, path, body) {
  const response = await fetch(BASE + path, {method, headers, body: body === undefined ? undefined : JSON.stringify(body)});
  if (!response.ok) throw new Error(`${method} ${path}: ${response.status} ${(await response.json()).detail}`);
  return response.json();
}

async function work(step) {
  await sleep(1000); // your agent's work
}

async function startRun(agentKey, agentName) {
  const id = (await call("POST", "/runs", {agent_key: agentKey, agent_name: agentName})).run_id;
  let sequence = 0;
  return {
    // Report one event; true when a stop was requested.
    async report(eventType, metadata = {}) {
      const event = {sequence: sequence++, event_type: eventType, timestamp: new Date().toISOString(), metadata};
      return (await call("POST", `/runs/${id}/events`, {events: [event]})).stop_requested;
    },
    // Wait for a person's decision in Sulcus; returns the approval as last read.
    async ask(toolCallId, toolName, context, timeoutMs = 600_000) {
      let approval = await call("POST", `/runs/${id}/approvals`, {tool_call_id: toolCallId, tool_name: toolName, context});
      const path = `/runs/${id}/approvals/${approval.approval_id}`;
      const deadline = Date.now() + timeoutMs;
      while (approval.status === "pending") {
        // A decision that arrived first is returned instead.
        if (Date.now() > deadline) return call("POST", `${path}/expire`, {reason: "timeout"});
        await sleep(2000);
        approval = await call("GET", path);
      }
      return approval;
    },
    finish: status => call("POST", `/runs/${id}/finish`, {status}),
  };
}

async function main() {
  const run = await startRun("deploy-bot", "Deploy bot");
  for (const step of ["build", "test"]) {
    if (await run.report("tool.execution_started", {tool_call_id: step, tool_name: step})) return run.finish("stopped");
    await work(step);
    await run.report("tool.execution_completed", {tool_call_id: step, tool_name: step});
  }
  const approval = await run.ask("deploy-1", "deploy", {command: "kubectl apply -f deploy.yaml", cwd: "/srv/app"});
  if (approval.stop_requested) return run.finish("stopped");
  if (approval.status !== "approved") return run.finish("failed"); // denied, or nobody decided in time
  await work("deploy");
  await run.report("tool.execution_completed", {tool_call_id: "deploy-1", tool_name: "deploy"});
  return run.finish("completed");
}

console.log((await main()).status);

Privacy

What Sulcus sees, and what it doesn’t.

Sulcus sees only what your agent sends, and keeps only an allow-listed part of it.

Sulcus keeps

  • Each event’s type, time and level
  • The metadata keys in the table below, when the value fits
  • Token totals from runtime.tokens.used, as your agent reports them
  • For an approval, its tool_name and, only if your agent sends them, a command, working directory (cwd), paths and host in its context

An approval’s context is shown only to the workspace members who can decide it, never in the run’s timeline or in emails. It’s bounded: command to 2,000 characters, cwd and each path to 1,024, and paths to 10 entries. A host that isn’t a hostname is dropped.

Sulcus doesn’t keep

  • Message text
  • A command, working directory, file path or host in an event; keys such as command, cwd, files and url_host are dropped
  • Any other metadata key, or a value of the wrong type or shape
  • Identifiers or names that look like credentials, such as sk-… or slc_…
  • The token itself, only its SHA-256 hash

Prompts, responses and tool arguments are never stored unless you put them in one of the kept keys; don’t. Credential-shaped text in an approval’s context is replaced with <redacted> on a best-effort basis, so leave secrets out.

Metadata keysKept when
agent_id, session_id, execution_id, thread_id, turn_id, item_id, tool_call_id, framework, status, error_type, request_id, approval_method, parent_agent_id, parent_run_id, trace_id, span_id, parent_span_id, modelA string of 1–256 characters from A-Z a-z 0-9 . _ : / - (request_id may also be an integer)
agent_name, tool_nameAs above, or a display name of at most 100 characters: letters, digits, spaces and . , ( ) ’ & : / + -
exit_code, duration_ms, change_count, http_status_code, input_tokens, output_tokens, total_tokens, prompt_tokens, completion_tokens, run_input_tokens, run_output_tokens, run_total_tokens, remaining_tokensA finite number
success, resumed, will_retryA boolean
run_id, origin, frameworkOverwritten by Sulcus; framework is set to custom-agent

Control

What you control, and how.

Everything here is cooperative.

Each control works only if your agent calls the API and acts on the answer. Sulcus can’t stop or block an agent that ignores the API.

Approve or deny

A workspace member approves or denies each request in Sulcus. Your agent acts only on approved: that check is in your code, as in the examples. An agent that acts without asking, or acts on a denial, isn’t stopped by Sulcus.

Request stop

Request stop on a custom agent’s run asks your agent to stop; it doesn’t stop it. Sulcus marks the run stop_requested, keeps it open, expires the approvals it was waiting for, so none of them can be approved afterwards, and refuses new approval requests. Heartbeats, event batches and approval polls then return "stop_requested": true. Your agent stops its work and finishes the run as stopped.

Until your agent does, the run shows Stop requested. An agent that never checks keeps running and can keep reporting events. Its run ends as stopped after 10 minutes of silence.

Limits

Limits per request, run and project.

A request the API can’t read is refused with 422 and a short message; the request’s content is never echoed back.

LimitValue
Events per batch50
Request body128 KB
Event ageAt most 24 hours old, at most 5 minutes in the future
Events kept per runThe latest 5,000
Open runs per project100
Approvals waiting per run20
Polls per approval60 per minute (429 beyond)
Requests per project600 per minute (429 beyond)
Refused tokens per client address60 per minute (429 beyond)
Silence before a run ends10 minutes

Troubleshooting

When something doesn’t connect.

401 on every request

The token is missing, wrong, revoked or replaced. Check that the agent sends the project’s current token, set in the shell the agent runs from. Sign-in tokens and other integrations’ tokens are refused too.

Still waiting for the first request

After you create the token, the project shows that it’s waiting for your agent’s first request, and updates on its own when it arrives. If it keeps waiting, check that the agent calls https://app.sulcus.dev with this project’s current token: a replaced or revoked token gets a 401.

The token belongs to the project

Not to a person. Removing someone from the workspace doesn’t revoke it, so replace it when someone who had it leaves.

Rotating has no overlap

A replaced token stops working at once; there’s no period when both work. Update every agent that uses it, and expect a 401 from any you miss.

A run ended as failed: “lost contact”

Sulcus received no events, heartbeats or approval polls for 10 minutes. Send a heartbeat at least every few minutes while the run is open.

404, 409 or 429

A run of another project is a 404. A 409 means the run has ended, the project already has 100 open runs, another agent used that run_key, or, for an approval, the run was asked to stop or already has 20 waiting. A 429 means a rate limit in the table above; approval polls send Retry-After.

Get started

Connect your first custom agent.

Create a Custom agent project under Integrations, set the token and run one of the examples. If you get stuck, email support@sulcus.dev.

Custom agents are in preview. Free during early access.