Result pages

Turn what your agent scraped into a link a human can open, instead of dumping 5,000 tokens of JSON into a chat window.

Prerequisite: you've been through the Quickstart and your agent can already scrape with exray.

What it is, and what it isn't

A result page is a read-only view of your own exray data. Your agent writes code deciding how to render results; exray serves it at a public address.

Here's everything v1 can do — read this before deciding to use it:

CanCannot
Read jobs and artifacts from your own projectStore anything (no KV / database / object storage)
Return HTML or JSONReach the network (the sandbox blocks outbound requests)
Route by path itself (one page can serve a whole site)Form submissions, counters, sessions, user-generated content
Set status codes and response headersRead another project's data

In one line: it's a presentation layer, not an application backend. Anything that needs to write — webhook receivers, forms, state machines — is out of scope for v1.

Mental model

Agent side (MCP tools)              Your side (one-time)
─────────────────────              ────────────────────
define_handler      writes page     set a username    → dashboard
publish_definition  publishes       enable the page   → dashboard / API

            https://<username>-<project>.exray.app

Both tracks have to meet before a page exists: the agent's token carries tool-plane permissions, but enabling a result page requires you. That's why publish_definition reports back which step is currently blocking (see "Handoff" below).

Three steps

Step 1: set a username

Dashboard → /settings/profile → pick a username.

It becomes the first segment of the address: lowercase letters, digits and hyphens, 3–30 characters. Without one, result pages are unavailable — nothing else is affected.

Step 2: have your agent write and publish the page

Tell your agent what you want. It calls define_handler to register the code, then publish_definition to publish it.

The entry point is handle(request, ctx): you get the full request and return a response. This example is the whole thing — it renders the most recent scrape:

import type { HandlerModule } from "@exray/exray-api";

export default {
  async handle(request, ctx) {
    const url = new URL(request.url);

    // /api → JSON, for machines
    if (url.pathname === "/api") {
      return { body: await ctx.data.getLatestResult("shop") };
    }

    // everything else → HTML, for humans
    const latest = await ctx.data.getLatestResult("shop");
    const jobs = await ctx.data.listJobs({ limit: 5 });
    return {
      body: `<h1>Latest scrape</h1>
        <pre>${JSON.stringify(latest, null, 2)}</pre>
        <p>${jobs.length} recent runs</p>`,
    };
  },
} satisfies HandlerModule;

ctx.data is the only data entry point, with three read-only methods:

MethodReturns
listJobs({ limit?, toolName? })Recent runs in this project (newest first, capped at 50)
getJobResult(jobId)One run's artifact; null if it isn't yours
getLatestResult(name)The most recent successful artifact from a published definition

You can return a Response, or { status?, headers?, body }. A string body is sent as HTML, anything else as JSON — that's the common case, so it's the default.

Step 3: enable it

Enable the result page for that project in the dashboard, or:

POST /api/projects/<project_id>/domain

This needs admin, which is why the agent can't do it. The address works immediately afterwards.

The address is <username>-<project-slug>.exray.app. The two parts together can't exceed 63 bytes — if they do, enabling fails with a clear message telling you to shorten the slug.

Handoff: how the agent knows what's blocking

When publish_definition publishes a handler, the response carries a site object:

next_actionMeaningWho acts
set_usernameNo username yetYou
enable_siteUsername exists, page not enabled. url is already the address it will haveYou
shorten_slugUsername plus project slug exceeds 63 bytesYou (change the slug)
nullLive — enabled: trueNobody

The point is that right after publishing, the agent can tell you "your page will be at X, go enable it" — rather than both sides assuming the job is done.

Security boundaries

Result pages run on a dedicated domain, exray.app, and none of exray's own services live on that domain — that separation is what makes the isolation work. Beyond that:

  • Page code runs sandboxed, cannot reach the network, and can only read this project's data
  • Responses get nosniff and a default CSP applied (your own CSP wins if you set one)
  • Set-Cookie headers carrying a Domain= attribute are stripped — this prevents users on the shared domain from reading each other's cookies
  • There are execution-time and response-size limits; exceeding them returns an error rather than emitting half a response

Common problems

Published but the URL 404s — usually it isn't enabled (step 3), or there's no username. Check next_action in the publish_definition response.

Changed the code but the page didn't change — run publish_definition again. Only the published version is served.

Can a page read someone else's data? No. Every ctx.data method filters by project, so even a real job id belonging to another project returns nothing.

Can I use my own domain? Not in v1.