Virtual Clock
The virtual clock for ensuring environments are timeless
An Agent on Virtual Time
An agent's machine has a date of its own, but the environment decides what time it is. Once the
gateway's clock is armed, the agent reads the time with get_time, the servers that follow the clock
stamp their data with it, and the trajectory records it, so every run happens on the same Monday
morning.
Every instance in the gateway topology has one virtual clock,
urn:agentenv:clock/v1 on its environment card.
The gateway provides it out of the box; a server needs code only to follow it.
It stays off until something arms it.
Arm It
PUT /clock/set-time starts the clock at virtual_time, running virtual_seconds_per_real_second
virtual seconds for every real one:
curl -sS -X PUT http://localhost:61854/clock/set-time -H 'content-type: application/json' \
-d '{"virtual_time": "2026-10-05T09:00:00Z", "virtual_seconds_per_real_second": 3600}'{
"armed": true,
"t0": "2026-10-05T09:00:00Z",
"virtual_seconds_per_real_second": 3600.0,
"virtual_time": "2026-10-05T09:00:00.144000Z"
}virtual_time is RFC 3339, with seconds and a Z or ±hh:mm offset. The speed defaults to 1,
real time; 0 freezes the clock, and 86400, a virtual day every real second, is the most. A
malformed time or a speed outside that range answers 400 and leaves the clock as it was.
How It Advances
Each read is t0 plus the real seconds since the arm, times the speed. So the clock moves on its
own, whether or not anything calls a tool, and reading it never changes it. Arming it again starts
it over from the new virtual_time, which is how every run can start at the same moment.
Read It
curl -sS http://localhost:61854/clock/time
curl -sS http://localhost:61854/clock/state{"virtual_time": "2026-10-05T21:30:00.047160Z"}{
"armed": true,
"t0": "2026-10-05T09:00:00Z",
"virtual_seconds_per_real_second": 3600.0,
"virtual_time": "2026-10-05T21:30:43.247160Z",
"env_get_time_url": "http://gateway:18765/clock/time"
}While the clock is off, /clock/time answers 404 with {"ok": false, "error": "clock not armed"},
and /clock/state answers {"armed": false}. env_get_time_url is where the env's servers read
the clock, from inside the instance.
What the Agent Sees
Arming adds a get_time tool to the agent's tools, and clearing takes it away. It takes no
arguments and returns the virtual time to the second, as {"current_time": "2026-10-05T21:30:00Z"};
its description tells the agent that the clock of the machine it runs on is not this environment's.
If one of the env's servers has a tool of its own named get_time, arming answers 409.
The Trajectory
While the clock is armed, every entry in /trajectory carries a virtual_time beside the real
timestamp_utc, so a run at 3600 times real speed still reads as a working day:
{"event_type": "tool_call", "tool_call": {"function_name": "get_time", "arguments": {}}, "event_id": "event_1", "timestamp_utc": "2026-09-30T14:02:11.482113+00:00", "virtual_time": "2026-10-05T21:30:00.047160Z"}Clear It
Clearing turns the clock off, and does no harm to a clock that is already off.
curl -sS -X POST http://localhost:61854/clock/clear{"ok": true, "armed": false}Follow the Clock in a Server
The gateway's clock doesn't change what time your server thinks it is. A server that stamps its
records, as send does here, follows the clock by advertising sync_time under
urn:agentenv:clock/v1 and reading the URL it is handed:
from datetime import datetime, timezone
import httpx
from agentenv_protocol import AgentEnvEnvironment, environment_card, extension, tool
@environment_card(name="email")
class EmailEnv(AgentEnvEnvironment):
def __init__(self) -> None:
self.inbox: list[dict] = []
self.sent: list[dict] = []
self.env_get_time_url: str | None = None
@extension(uri="urn:agentenv:clock/v1", description="Sync this server to the gateway's virtual clock.")
async def sync_time(self, env_get_time_url: str) -> dict:
self.env_get_time_url = env_get_time_url
return {"now": await self._now()}
async def _now(self) -> str:
if self.env_get_time_url:
async with httpx.AsyncClient() as client:
resp = await client.get(self.env_get_time_url, timeout=10)
if resp.status_code == 200:
return resp.json()["virtual_time"]
return datetime.now(timezone.utc).isoformat()
@tool()
async def send(self, to: str, subject: str, body: str) -> dict:
"""Send an email."""
sent_at = await self._now()
self.sent.append({"to": to, "subject": subject, "body": body, "sent_at": sent_at})
return {"sent": len(self.sent), "sent_at": sent_at}Time Triggers
A trigger with "when": {"type": "time", "at": ...} fires when the virtual clock reaches at, an
RFC 3339 time or a duration from t0 such as PT8H. The gateway checks its time triggers once a
real second and after each watched tool call, so at 86400 one can fire up to a virtual day late.
Triggers covers the rest.
Waits on the Clock
A server's own extensions can run on the clock too. On a synced server that implements it,
urn:agentenv:set-async-wait/v1 with scale_to_virtual measures a submitted job's wait in virtual
seconds, so a 60-second job at speed 60 is ready after about a real second. Async Waits
covers the extension.
In a Task
In a task, the sync_env_clock step arms the clock and syncs the servers that follow it, as
Env control describes.
Last updated on