Recipes

Watch a page and get alerted

Check a page every 15 minutes and alert Slack only when its state changes.

Check a public page or API every 15 minutes, have the agent report what it finds as JSON, and post to Slack only when the state changes. Ephemeral runs the check on a schedule; a small script of yours reads the result and decides whether to alert.

A check that fetches one page takes about 20 seconds. Most of the cost is compute, billed at $0.0002 per second, plus $0.001 for the page fetch. The share of the model depends on the model you choose, so read cost_micros on the first runs and adjust the schedule or the model from there.

  1. Gather what you need

    • An API key with the admin role, because only owners and admins can create schedules. See roles and keys.
    • The page or API to watch, on one domain, here https://status.example.com.
    • A Slack incoming webhook URL, or the address of your own alerting service.
    • Somewhere to run a script every few minutes: cron, a scheduled CI job or a serverless timer.
  2. Save the workflow

    The workflow fetches the page and answers in a fixed JSON shape. Ephemeral does not enforce an output schema: the run's output is free text. So the prompt asks for JSON only, and your script parses it defensively in step 4.

    curl -X PUT $EPHEMERAL_URL/v1/workflows/check-status-page \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "prompt": "Fetch the page at input.url with web_fetch. Reply with one JSON object and nothing else: {\"ok\": boolean, \"http_status\": number, \"summary\": string}. Set ok to true only when the response status is 200 and the page reports no ongoing incident, outage or degraded service; resolved past incidents do not count. summary is one sentence of at most 200 characters.",
        "web": { "allow": ["status.example.com"], "search": false },
        "shell": false,
        "timeout_seconds": 90
      }'

    Web access is limited to status.example.com and search is off, so the check can read that site and nothing else. The URL to check arrives as input, so the same workflow can serve more than one schedule on that domain.

  3. Create the schedule

    Preview the timetable first. It returns the next five start times and saves nothing.

    curl -X POST $EPHEMERAL_URL/v1/schedules/preview \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{ "cron": "*/15 * * * *", "timezone": "UTC" }'

    Then create it. The input and the budget are the same for every run.

    curl -X POST $EPHEMERAL_URL/v1/schedules \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "workflow": "check-status-page",
        "name": "Status page, every 15 minutes",
        "cron": "*/15 * * * *",
        "input": { "url": "https://status.example.com" },
        "max_cost_micros": 100000
      }'

    Save the schedule's id. The budget of 100,000 micros ($0.10) is reserved from your credits at each start and released when the run settles; it must cover the compute for the full timeout, which here is 90 seconds, $0.018. If a check is still running when the next start is due, that start is skipped, because skip_if_running is true by default.

  4. Start one now and read the answer

    Do not wait for the timetable. POST /v1/schedules/{id}/run starts the workflow now with the schedule's input and budget and answers 202 with a run that carries the schedule's ID. Then list the schedule's runs, newest first:

    curl -X POST $EPHEMERAL_URL/v1/schedules/$SCHEDULE_ID/run \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY"
    
    curl "$EPHEMERAL_URL/v1/runs?schedule=$SCHEDULE_ID&limit=1" \
      -H "Authorization: Bearer $EPHEMERAL_API_KEY"

    output is a string that holds the JSON. Models sometimes wrap it in a code fence or add a sentence despite the instruction, so the script below takes everything from the first { to the last } and parses that.

  5. Alert only when something changed

    Scheduled runs do not send callbacks, so your script asks for the schedule's newest run instead. It remembers the last run and the last state in a file, ignores runs it has seen or that are still going, and posts to Slack only when the state differs from the previous one. A check that fails or returns something unreadable counts as its own state, unknown, so a broken monitor does not look like a healthy page.

    monitor.mjs (Node.js 18 or later)
    import { readFile, writeFile } from "node:fs/promises";
    
    const { EPHEMERAL_URL, EPHEMERAL_API_KEY, SCHEDULE_ID, SLACK_WEBHOOK_URL } = process.env;
    const ACTIVE = new Set(["reserving", "queued", "starting", "running"]);
    const stateFile = "monitor-state.json";
    const state = JSON.parse(await readFile(stateFile, "utf8").catch(() => "{}"));
    
    const response = await fetch(`${EPHEMERAL_URL}/v1/runs?schedule=${SCHEDULE_ID}&limit=1`, {
      headers: { Authorization: `Bearer ${EPHEMERAL_API_KEY}` },
    });
    if (!response.ok) throw new Error(`Ephemeral returned ${response.status}`);
    const [run] = (await response.json()).items;
    if (!run || ACTIVE.has(run.status) || run.id === state.run) process.exit(0); // nothing new to judge
    
    let label = "unknown"; // the check itself did not produce an answer
    let summary = run.error ?? `The run ${run.status}.`;
    if (run.status === "succeeded") {
      try {
        const result = JSON.parse(run.output.match(/\{[\s\S]*\}/)[0]); // tolerate text or a code fence around the JSON
        label = result.ok === true ? "ok" : "down";
        summary = result.summary;
      } catch {
        summary = "The check did not return valid JSON.";
      }
    }
    
    if (label !== (state.label ?? "ok")) {
      await fetch(SLACK_WEBHOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ text: `Status page: ${label}. ${summary} (${run.id})` }),
      });
    }
    await writeFile(stateFile, JSON.stringify({ run: run.id, label }));

    Run it every five minutes, for example with */5 * * * * in cron, with the four variables set. The first check stays quiet when the page is healthy; every later change, including the recovery from down to ok, posts once.

Good to know

TopicBehavior
Overlapping checksWith skip_if_running on, a start is skipped while the previous run is still going, and the schedule notes it in last_error. Set it to false only if overlap is harmless.
A schedule that stopsAfter five failed starts in a row, for example because credits ran out, the schedule turns itself off and no new runs appear. Check enabled and last_error with GET /v1/schedules/{id} now and then, or top up credits and set enabled to true.
Changing the intervalPATCH /v1/schedules/{id} with a new cron; the next start is counted from now.
Pausingephemeral schedules pause ID and resume, or enabled in a PATCH.
Keep the check read-only
This workflow has no connections and no shell, and web access reaches one domain. Because the run decides what to report, treat its answer as a signal to look at, not as a command to carry out automatically.
Want a callback instead?
If you would rather receive a signed callback than poll, start the runs from your own scheduler with POST /v1/runs, the same workflow and a callback. The receiver is the one in the payment investigation recipe. You then give up Ephemeral's overlap guard and timetable, so keep schedules for work you do not need to push.
© 2026 EphemeralProduced by erni.works
esc

Nothing matches “”.