Skip to main content

Connecting to Watchlight Cloud

Watchlight Cloud is a control centre for Developer Edition deployments: one place for decision history, agent inventory, environments and team access.

The authorization decision stays in your process. Cloud is a control plane around an engine it does not run — your policies are evaluated in-process by the same compiled engine as before, at the same latency, and a Cloud outage does not stop your agents deciding. What Cloud adds is everywhere the Developer Edition already invites your code in: the audit sink, the counter source, the approval store.

Early access

Cloud is in early access, and these steps need an account. Ask for one. Nothing on this page requires a Developer Edition upgrade — it works against watchlight and @watchlight/sdk as published.

1. Create a deployment

In the control centre, create a project and an environment, then a deployment inside it. You are shown a token once:

wlc_prod_XxSm7…

The token carries the tenant, project and environment. Nothing your process sends can claim to be from somewhere else, which is why a development deployment cannot write into production history even by accident.

Copy the ingest endpoint the deployment page shows alongside it, and keep both out of your source:

export WATCHLIGHT_CLOUD_URL="…"          # shown on the deployment page
export WATCHLIGHT_CLOUD_TOKEN="wlc_prod_…"

2. Send the audit trail

The trail is the whole point, and it is one option you already have: audit_sink. Batch it — the sink is called inside the decision, so an unbatched network call there costs every governed call a round trip.

import os, requests
from watchlight import Watchlight

URL = os.environ["WATCHLIGHT_CLOUD_URL"]
HEADERS = {"Authorization": f"Bearer {os.environ['WATCHLIGHT_CLOUD_TOKEN']}"}

def send(batch): # a LIST, because batching is on
requests.post(f"{URL}/v1/ingest", json=batch, headers=HEADERS, timeout=10)

govern = Watchlight(
agent="my-agent",
audit_sink=send,
audit_sink_batch=100,
audit_sink_interval=2.0,
)
const send = (batch) =>
fetch(`${process.env.WATCHLIGHT_CLOUD_URL}/v1/ingest`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.WATCHLIGHT_CLOUD_TOKEN}`,
"Content-Type": "application/json" },
body: JSON.stringify(batch),
});

const govern = new Watchlight({
agent: "my-agent",
auditSink: send,
auditSinkBatch: 100,
auditSinkInterval: 2000,
});

Run a governed call and the decision appears in the control centre. Worth knowing:

  • Records are stored exactly as sent. Tenant and environment are recorded in columns beside the record, never merged into it, so the value-free contract survives the trip.
  • A partial batch returns 207 with a per-record status rather than failing the whole batch on one bad record.
  • Send X-Watchlight-Dropped: <trail.dropped> if you want the control centre to show a gap indicator. The queue is bounded, and a trail with unreported holes is worse than one that admits them.
  • A rotated token keeps working for a grace period, and responses carry a Warning header until you switch.

3. Quotas from Cloud, instead of the local file

Optional, and on the decision path — read the warning below before turning it on.

counters() folds the local audit.jsonl, which on an ephemeral host is the copy that cannot be trusted. Point counter_source at Cloud and the quota spans every replica:

def count(query):
r = requests.post(f"{URL}/v1/counters", json=query, headers=HEADERS, timeout=2)
r.raise_for_status()
return r.json()["count"]

govern = Watchlight(agent="my-agent", audit_sink=send,
audit_sink_batch=100, counter_source=count)

Cloud counts decision rows only — the rule the Developer Edition asks a source to honour is enforced server-side, so a query filtered on principal and window alone cannot over-count.

This one is synchronous

counter_source is read inside authorize(), and it fails closed. If Cloud is slow your agents are slow; if Cloud is unreachable your agents are denied. It is opt-in per environment for that reason. Read it with counters_async from an async context binding, and handle the failure the way you handle truncated — omit the counter and let the policy deny.

4. Approvals that hold across replicas

Also optional, also on the decision path. The default approval store is a dict in one process, so "single use" means once per replica. Point approval_store at Cloud and it means once, full stop:

class CloudApprovals:
def add(self, id: str, expires_at: int) -> bool:
r = requests.post(
f"{URL}/v1/approvals/reserve",
json={"id": id, "expires_at": datetime.fromtimestamp(expires_at / 1000,
tz=timezone.utc).isoformat()},
headers=HEADERS, timeout=2,
)
r.raise_for_status()
return r.json()["reserved"] # False means someone else consumed it

govern = Watchlight(agent="my-agent", approval_store=CloudApprovals())

The reservation is an atomic check-and-set — the primary key is the check — so two replicas consuming the same token concurrently produce exactly one True.

What stays where

Runs in your processRuns in Cloud
The authorization decision✅ the compiled engine, in-process❌ never
Your policy set✅ loaded by your appauthoring and review
The audit trailwritten locallystored, queryable, retained
Quotasfolded locally by defaultoptional, per environment
Approvalsper-process by defaultoptional, per environment

Central policy distribution is not available yet. Cloud is the authoring and review surface, and your process still loads its own policy file. When distribution ships it will cache the last known good bundle on disk and never distribute an empty one, because default-deny means an empty set denies everything.

Next