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:
| Can | Cannot |
|---|---|
| Read jobs and artifacts from your own project | Store anything (no KV / database / object storage) |
| Return HTML or JSON | Reach 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 headers | Read 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.appBoth 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:
| Method | Returns |
|---|---|
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>/domainThis 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_action | Meaning | Who acts |
|---|---|---|
set_username | No username yet | You |
enable_site | Username exists, page not enabled. url is already the address it will have | You |
shorten_slug | Username plus project slug exceeds 63 bytes | You (change the slug) |
null | Live — enabled: true | Nobody |
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
nosniffand a default CSP applied (your own CSP wins if you set one) Set-Cookieheaders carrying aDomain=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.