Scheduling and liveness ("TTL") service for autonomous AI agents: cron and one-off HTTP webhook jobs (signed, retried) and heartbeat monitors — a dead man's switch that alerts when an agent stops pinging. Agents register with an EVM wallet and pay with x402, so there is no human account, no dashboard and no credit card in the loop. For agents holding funds there is also an on-chain dead man's switch contract on Base.
Live at https://time2live.xyz · API https://time2live.xyz/openapi.json · remote MCP server
https://time2live.xyz/mcp.
Network mode is surfaced everywhere so agents and humans know whether money is real: the /
summary carries network.mode (live on Base mainnet, test on a testnet, or disabled), and
the landing page, /llms.txt, the dashboard and the OpenAPI description all label it. Mainnet is a
config switch (X402_NETWORK=eip155:8453 with a mainnet facilitator; KEEPER_CHAIN_ID=8453); a
mixed testnet/mainnet deployment is rejected at startup.
If you are an agent (or building one): start at /llms.txt
for a compact guide, then Authentication and either
Scheduled jobs or Heartbeat monitors.
Prefer tools? See MCP server and discovery.
If you are running it: Local development below; docs/OPERATIONS.md is the full Hetzner deploy, backup, restore and monitoring runbook; docs/PLAN.md has the scope, architecture and design decisions.
Requirements: Node 22+, Docker (Docker Desktop on Windows, or WSL2). All commands below work in PowerShell, WSL and Git Bash.
cp .env.example .env # PowerShell: Copy-Item .env.example .env
npm install
docker compose up -d postgres # Postgres on 127.0.0.1:5432 (+ time2live_test database)
npm run migrate
npm run dev:api # http://localhost:3000/healthz
npm run dev:worker # in another terminalFull stack from the production image: docker compose --profile app up -d --build.
| Command | Purpose |
|---|---|
npm test |
unit + integration tests (needs Postgres; override with TEST_DATABASE_URL) |
npm run lint / npm run format |
ESLint / Prettier |
npm run typecheck |
TypeScript without emitting |
npm run db:generate -- --name <x> |
generate a SQL migration after editing src/core/db/schema.ts |
Agents authenticate with an EVM wallet — no passwords or email:
POST /v1/auth/challenge{"address":"0x…","chainId":8453}→ an EIP-4361 (SIWE)message.- Sign
messageverbatim with the wallet (EIP-191personal_sign). POST /v1/auth/verify{"message":"…","signature":"0x…","keyName":"my-agent"}→apiKey.key(shown once; only its SHA-256 hash is stored). Use it asAuthorization: Bearer t2l_….
Challenges are single-use and expire after 5 minutes. EOA signatures are verified offline;
smart-contract wallets (ERC-1271/6492) require BASE_RPC_URL / BASE_SEPOLIA_RPC_URL.
The OpenAPI document is served at /openapi.json.
curl -X POST http://localhost:3000/v1/jobs -H "Authorization: Bearer $T2L_KEY" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: wake-1' -d '{
"name": "wake agent",
"schedule": { "type": "cron", "expression": "*/15 * * * *", "timezone": "Europe/Helsinki" },
"target": { "url": "https://agent.example.com/wake", "method": "POST",
"headers": { "Authorization": "Bearer my-agent-token" }, "body": { "task": "wake" } },
"timeoutMs": 10000, "maxAttempts": 5 }'- Schedules: 5-field cron (+
@hourly/@daily/…, minimum interval 1 min) with an IANA timezone, or{"type":"once","at":"<ISO time>"}. - After downtime, missed occurrences are collapsed into a single catch-up run (the schedule
then jumps to the next future slot) — never a replayed backlog. Add
"freshnessSeconds": N(60 … 31536000) to drop even that catch-up when its slot is older thanNseconds: the run is recordedskipped(not delivered, not charged). Omit it to deliver regardless of lateness. Clients can also gate on theT2L-Scheduled-Forheader. - Delivery:
2xxsucceeds; timeouts, network errors,408/425/429/5xxare retried with exponential backoff (10 s, 20 s, 40 s … ≤ 1 h, ±20 % jitter,Retry-Afterhonoured); other statuses fail immediately. Every attempt is logged (GET /v1/runs/{id}); history is kept 30 days. - Targets:
httpson ports 443/8443 only. Private, loopback, link-local (incl.169.254.169.254), CGNAT, ULA, NAT64 and our own IPs are refused — checked at creation and again at connect time after DNS resolution; every redirect hop (max 3) is re-validated and custom headers are dropped on cross-origin redirects. - Header values are stored encrypted and never returned.
Each request carries T2L-Signature: t=<unix>,v1=<hex>, T2L-Delivery-Id (stable across
retries — use it to deduplicate), T2L-Attempt, T2L-Event, T2L-Job-Id and
T2L-Scheduled-For. Get your secret from GET /v1/account/webhook-secret.
import crypto from 'node:crypto';
function verify(secret, rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}# create (the response contains pingUrl)
curl -X POST http://localhost:3000/v1/monitors -H "Authorization: Bearer $T2L_KEY" \
-H 'Content-Type: application/json' -d '{
"name": "agent-7", "ttlSeconds": 300, "graceSeconds": 60,
"alerts": { "webhookUrl": "https://ops.example.com/t2l-alerts", "telegram": true } }'
# ping from the agent (no API key; the unguessable id is the credential)
curl -fsS -X POST http://localhost:3000/v1/heartbeat/mon_…- States:
new(never pinged, never alerts) →alive→deadwhen no ping arrives withinttlSeconds + graceSeconds→aliveagain on the next ping.pausedsuppresses alerts; resuming starts a fresh window. - Alerts:
monitor.downandmonitor.up(recovery) over up to four independent channels — a signed webhook (sameT2L-Signaturescheme as jobs, JSON body with the monitor snapshot), an optional secondary webhook for redundancy, a Telegram message, and email via an external provider (independent transport/infra, so one channel being down cannot silence the alert). Each channel is delivered and retried separately with backoff (6 attempts). Email requires the server to haveRESEND_API_KEYset. Transitions are listed atGET /v1/monitors/{id}/events(30 days). - Telegram:
POST /v1/account/telegram/linkreturns at.medeep link; press Start in Telegram to link the chat, send/stopto unlink. Operators must setTELEGRAM_BOT_TOKEN,TELEGRAM_BOT_USERNAME,TELEGRAM_WEBHOOK_SECRETand runadmin telegram-webhookonce. - Tiers: 1 monitor before activation, 3 after. Further monitors cost $0.25 per 30 days from
credits (
billing.plan = "paid"); when a renewal cannot be paid the monitor is paused and amonitor.unpaidalert is sent.
| What | Price |
|---|---|
| Activation (one-off) | $0.10 — raises the free tier from 1 monitor + 50 runs/month to 3 + 100 |
| Run beyond the monthly free | $0.0005 from credits ($1 = 2,000 runs) |
| Monitor beyond the tier | $0.25 per 30 days from credits |
| Credit packs | $1, $5, $20 |
Payments use x402 v2 (USDC on Base; Base Sepolia while testing), so an agent
pays without a human. Any call that needs money — POST /v1/monitors, POST /v1/monitors/{id}/resume,
POST /v1/jobs/{id}/trigger, POST /v1/billing/activate, POST /v1/billing/credits — answers
402 with a PAYMENT-REQUIRED header (cheapest offer first; the body carries the same JSON plus
error). Sign one offer with an x402 client and retry the same request with
PAYMENT-SIGNATURE: the payment is settled, activation/credits applied and the request completes
in one round trip; the receipt comes back in PAYMENT-RESPONSE. Any payment also activates the
free tier. A settlement is credited at most once (unique transaction), and 402s are never stored
under an Idempotency-Key, so retrying with the same key works.
import { wrapFetchWithPayment } from '@x402/fetch';
import { x402Client } from '@x402/core/client';
import { ExactEvmScheme } from '@x402/evm/exact/client';
import { privateKeyToAccount } from 'viem/accounts';
const client = new x402Client().register(
'eip155:84532',
new ExactEvmScheme(privateKeyToAccount(PK)),
);
const pay = wrapFetchWithPayment(fetch, client);
await pay('https://time2live.xyz/v1/billing/credits', {
method: 'POST',
headers: { authorization: `Bearer ${T2L_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ pack: 1 }),
});x402 SDKs cap a single payment at $1 by default; raise maxAmountPerPayment for the $5/$20
packs. GET /v1/billing shows balance, tier and prices, GET /v1/billing/payments the settled
payments. Scheduled runs that cannot be paid are recorded as skipped (the job keeps its
schedule and resumes producing runs as soon as there is allowance or credit).
For agents that hold funds, contracts/ contains DeadMansSwitchFactory + DeadMansSwitch
(Foundry, OpenZeppelin v5).
- One isolated clone per switch holds ETH and up to 20 ERC-20s.
- The agent or owner pings it; after
ttlwithout a ping, anyone cantrigger()and everything goes to the beneficiary. - Once expired, the switch can't be revived or emptied by the owner.
- Fee-on-transfer and rebasing tokens are handled.
- Optional
triggerRewardBps(≤ 5%) pays whoever callstrigger(), so a funded switch's liveness doesn't depend on our keeper — anyone (or an MEV bot) is incentivised to trigger it on time. - Tested with unit, fuzz and invariant tests, and checked with Slither.
The worker's keeper discovers switches from factory events and triggers expired ones (see
docs/OPERATIONS.md §8c). Once configured, GET / and /llms.txt advertise the factory
address. Details: contracts/README.md.
Audit status: unaudited. An internal review fixed one High and one Medium (zero outstanding
critical/high), but that is not a third-party audit. A self-contained audit packet is at
contracts/audit/SCOPE.md; the audit target is tag audit-v1, which
matches the verified bytecode of the live mainnet factory. We are engaging an external auditor and
will publish the report here — until then, don't deposit more than you'd be willing to lose.
POST /mcp is a remote MCP server (Streamable HTTP, stateless, JSON responses). Tools:
register_challenge, register, get_status, create_job, list_jobs, trigger_job,
delete_job, create_heartbeat, list_monitors, ping, activate, buy_credits. Each tool
calls the REST API in-process, so auth, rate limits, idempotency (idempotencyKey argument) and
billing behave identically.
{
"mcpServers": {
"time2live": {
"type": "http",
"url": "https://time2live.xyz/mcp",
"headers": { "Authorization": "Bearer t2l_…" }
}
}
}An agent without a key can connect without the header, call register_challenge, sign the
message with its wallet and call register; later tools take the key via the header or an
apiKey argument. Paid tools follow the x402 MCP transport: the result carries
PaymentRequired (isError, structuredContent), and an x402 MCP client (@x402/mcp
createx402MCPClient) pays and retries with _meta["x402/payment"]; the receipt is returned in
_meta["x402/payment-response"].
Discovery: GET / (service summary and links), GET /llms.txt, GET /openapi.json and the MCP
Server Card at /.well-known/mcp/server-card.json (alias /.well-known/mcp.json; SEP-1649
format, generated from the live tool list).
GET / uses content negotiation: browsers (Accept: text/html) get a lightweight landing page
(single self-contained HTML file, no build step), while agents and curl get the JSON summary —
so the discovery contract at / is unchanged for machines.
The service runs on a single Hetzner VPS with Docker Compose (api, worker, Postgres, Caddy for automatic HTTPS). Images are built on the server from a git checkout — no registry or deploy secrets. docs/OPERATIONS.md is the authoritative runbook; the essentials:
- Provision (once):
deploy/create-server.ps1creates the server and firewall;provision.shhardens it (SSH key only, no root login, ufw, fail2ban, unattended upgrades, Postgres not exposed) and installs a read-only deploy key. Configure secrets in/opt/time2live/.env(ENCRYPTION_KEY,POSTGRES_PASSWORD, and optionalTELEGRAM_*,X402_*,KEEPER_*). - Deploy:
deploy/deploy.ps1 -Ref main(orssh time2live /opt/time2live/update.sh <sha>) — builds the image, runs migrations before starting, does a deep health check and rolls back on failure, keeping the last 5 images. - Backups: nightly encrypted
pg_dumpto a Hetzner Storage Box via restic (systemd timer); restore is documented and tested in OPERATIONS §6. - Monitoring:
GET /healthz?deep=1, the worker's own self-heartbeat monitor, and an external uptime check. Enabling x402 (§8b) and the on-chain keeper (§8c) is optional and off by default.
Secrets live only in /opt/time2live/.env on the server, never in the repo.
Reliability (current posture): the service runs on a single node — there is no high-availability
layer yet, so the recovery path for a host failure is restore-from-backup (OPERATIONS §6), with an
RPO of one night and an RTO bounded by reprovision + restore. This affects off-chain scheduling and
heartbeats only: on-chain switch liveness does not depend on our node — trigger() is
permissionless and reward-incentivised (above), so a funded switch still fires if our keeper is
down. HA and a hosted public testnet are on the roadmap, not done.
npm run admin -- show <accountId|address>
npm run admin -- freeze <accountId|address> --reason "abuse report"
npm run admin -- unfreeze <accountId|address>
npm run admin -- telegram-webhook # register <PUBLIC_BASE_URL>/telegram/webhook with Telegram
# production: docker compose exec api node dist/bin/admin.js …A frozen account gets 403 account_frozen on every API call and cannot obtain new keys; its
jobs stop producing runs and pending deliveries are cancelled. Its pings are still accepted,
but no alerts are sent.
Agents operate; the human owner supervises. These are API-key authed, so the owner (or a browser holding a key) can use them:
POST /v1/account/pause-all— kill switch: pauses every active job (cancelling pending runs) and every monitor (no more alerts). Nothing is deleted; resume individually when ready.POST /v1/account/keys/revoke-all— revokes every API key, including the caller's, locking the agent out at once. Sign in again with the wallet to mint a new key.GET /v1/account/overview— one read-only call returning account, tier, usage, credit balance, job/monitor status counts, and the most recent monitors, jobs and payments.
GET /dashboard is a lightweight browser page (single self-contained HTML file) where the human
owner connects their wallet, signs once, and supervises the fleet: account, credits, monitors,
jobs and payments, with the two emergency-stop buttons. Sign-in uses POST /v1/auth/session, which
verifies a SIWE signature like /v1/auth/verify but returns a short-lived (1 h) operator session
token (t2ls_…) instead of a persistent API key — nothing is stored server-side, and it
deliberately survives revoke-all, so the human keeps access while the agent's keys are cut. The
token authenticates the same endpoints as an API key (Authorization: Bearer t2ls_…); agents keep
using API keys.
GET /healthz checks the process only; GET /healthz?deep=1 also checks Postgres and that a
worker ticked within HEALTH_MAX_TICK_AGE_MS (503 otherwise) — point external uptime checks there.
Copyright © 2026 time2live.xyz. Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). You may use, modify and self-host it; if you run a modified version as a network service, you must make your source available to its users (AGPL §13). For a commercial license without the AGPL obligations, contact the maintainer.