Skip to content
AgentEnv Framework

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:

Terminal
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}'
200
{
  "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

Terminal
curl -sS http://localhost:61854/clock/time
curl -sS http://localhost:61854/clock/state
GET /clock/time
{"virtual_time": "2026-10-05T21:30:00.047160Z"}
GET /clock/state
{
  "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:

GET /trajectory
{"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.

Terminal
curl -sS -X POST http://localhost:61854/clock/clear
200
{"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:

email/server.py
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

Ask a question · Report an issue

On this page