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.
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
207with 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
Warningheader 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.
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 process | Runs in Cloud | |
|---|---|---|
| The authorization decision | ✅ the compiled engine, in-process | ❌ never |
| Your policy set | ✅ loaded by your app | authoring and review |
| The audit trail | written locally | stored, queryable, retained |
| Quotas | folded locally by default | optional, per environment |
| Approvals | per-process by default | optional, 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
- Extending Watchlight — every seam Cloud attaches to, and what else you can plug into them.
- The audit trail — record kinds and fields in full.