Preview environments are one of those features that feel like magic the first time a reviewer clicks a link in a pull request and lands on the actual change, running. They are also historically annoying to build: a pile of CI YAML, a hosting account, DNS, and cleanup logic that always seems to leak environments.
This post builds one with the official TypeScript SDK, start to finish, with the rough edges left in. The goal: every pull request gets its own real URL, and that URL disappears when the PR closes. No dashboard clicking, no orphaned environments.
Quick answer
Install @server4agent/sdk, and from a CI job you can provision a server, start a build, and publish an ephemeral project to a live *.apps.server4agent.com URL in a handful of typed lines. Because the project is marked ephemeral, a single cleanup() call on PR close tears the whole thing down. The client is written in TypeScript, ships its own types, runs on Node 18 and newer, and retries transient failures automatically.
Key takeaways
- The SDK is for the code around your agent: CI jobs, Node backends, webhook receivers, admin scripts.
- Ephemeral projects are the right primitive for per-PR environments, because they are built to be cleaned up.
- Handles returned by
create()mean you rarely thread IDs through your CI script. - Errors are typed subclasses, so cleanup logic can be precise rather than catch-all.
- The same URL you preview is the same URL that would go live, because slugs are stable.
Install
npm install @server4agent/sdkIt is ESM, targets Node 18 and newer so it uses the built-in fetch, and ships its own type definitions. In CI, set your key as a secret and export it as SERVER4AGENT_API_KEY; the client reads it automatically.
The whole preview job
Here is the core of it. Run this from CI on every pull request:
// Give every PR a real, disposable URL, then tear it down when the PR closes.
// lifecycle "ephemeral" marks the project for cleanup.
import { Server4Agent } from "@server4agent/sdk";
const client = new Server4Agent();
const pr = process.env.PR_NUMBER!;
const server = await client.servers.create({ task: `pr-${pr}` });
const project = await server.projects.create({
name: `pr-${pr}`,
visibility: "public",
lifecycle: "ephemeral",
});
const build = await server.builds.start("Install deps, build the app, and deploy it.");
await build.wait();
console.log(`Preview: ${project.url}`);
// On PR close: await project.cleanup();That is the entire happy path. A reviewer opens the PR, CI runs this, and a comment appears with a working link. Let me unpack the decisions, because each one earns its place.
Why ephemeral is the whole trick
The lifecycle: "ephemeral" flag is what makes this sustainable. Persistent projects are for standing work: a dashboard, a monitor, a bot that should outlive any single task. A per-PR preview is the opposite. It exists for the life of a review and should vanish afterward. Marking it ephemeral means cleanup() reliably reclaims it, so you are not left with fifty stale environments by the end of a busy sprint.
The distinction between persistent and ephemeral projects is the same one that shows up everywhere in the product, and it is worth internalizing. Persistent projects are why AI agents need persistent workspaces in the first place; ephemeral ones are the clean counterpart for one-shot work.
Handles keep the CI script short
Notice that server.projects.create() and server.builds.start() are called on the server handle, not on IDs. client.servers.create() resolves to a rich handle that is both a data record and an action surface, so the sub-resources are already scoped to the right server:
const server = await client.servers.get("srv_abc");
await server.tasks.create("..."); // scoped to this server
await server.files.write("app.py", "...");
await server.deploy();
await server.refresh(); // re-fetch in place
const project = await client.projects.get("prj_xyz");
await project.update({ visibility: "public" });In a CI script, where you often want to log the URL, poll a build, and later clean up, this is the difference between a readable file and a tangle of IDs.
Watching the build
build.wait() polls until the build reaches a terminal state, and it accepts tuning so a long build does not look hung:
const build = await server.builds.start("Install deps, build the app, and deploy it.");
await build.wait({ pollIntervalMs: 3_000, timeoutMs: 900_000 });
if (build.status !== "succeeded") {
// Post the failure back to the PR and bail out.
throw new Error(`Preview build failed: ${build.status}`);
}
console.log("Preview:", project.url, "artifacts:", build.artifacts);Because the finished build carries a URL and the list of artifacts it produced, the CI step has everything it needs to leave a useful comment on the PR: the link to click and the files that changed.
Clean up precisely with typed errors
The cleanup step runs when the PR closes. It should be defensive, because the environment may already be gone, and typed errors let you tell "already cleaned up" apart from a real problem:
import {
Server4AgentError,
NotFoundError,
RateLimitError,
APIStatusError,
} from "@server4agent/sdk";
try {
const project = await client.projects.get(`prj_for_pr_${pr}`);
await project.cleanup();
} catch (err) {
if (err instanceof NotFoundError) {
// Already cleaned up. Nothing to do.
} else if (err instanceof RateLimitError) {
// 429, already retried; quote err.requestId to support.
} else if (err instanceof APIStatusError) {
console.error(err.status, err.code, err.message, err.requestId);
throw err;
} else if (err instanceof Server4AgentError) {
throw err; // timeouts, network
}
}Retryable failures (429, 5xx, network blips) are retried automatically with exponential backoff before they surface, and you can tune the transport per client:
new Server4Agent({ timeoutMs: 30_000, maxRetries: 4 });Close the loop with a webhook
Polling in CI is fine, but if you would rather react to events, subscribe to build and deploy events and verify every delivery. The SDK ships a helper for that:
import express from "express";
import { verifyWebhookSignature } from "@server4agent/sdk";
const app = express();
app.post("/hooks/server4agent", express.text({ type: "*/*" }), async (req, res) => {
// req.body must be the raw string; verify before you JSON.parse it.
const ok = await verifyWebhookSignature(
process.env.SERVER4AGENT_WEBHOOK_SECRET!,
req.body,
req.header("Server4Agent-Signature"),
);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body);
// e.g. comment "Preview ready" back on the PR when deployment.live arrives.
res.status(204).end();
});The one rule: verify the raw body before you parse it. Re-serializing the JSON changes the bytes and breaks the signature. The full event list is in the webhooks docs.
What you end up with
The finished workflow is small enough to read in one sitting and does something that used to take a platform team a sprint:
- A PR opens, CI provisions a server and an ephemeral project.
- The build agent installs, builds, and deploys the change.
- CI comments the live
*.apps.server4agent.comURL back on the PR. - Reviewers click a real running app instead of imagining the diff.
- The PR closes, a cleanup step calls
cleanup(), and the environment is gone.
That is the same prompt-to-URL loop the whole platform is built around, applied to code review. If you want the manager-facing version of why this matters, why every AI agent should be able to return a URL makes the case, and from prompt to live URL: uptime monitor is another build in the same spirit.
FAQ
How do I install the Server4Agent TypeScript SDK?
Run npm install @server4agent/sdk. It is written in TypeScript, ships its own type definitions, and runs on Node 18 and newer using the built-in fetch. It reads SERVER4AGENT_API_KEY from the environment, or accepts { apiKey } in the constructor.
Is there a separate async client?
No. Every method already returns a Promise, so you just await. There is no sync-versus-async split the way there is in Python.
How do I give each pull request its own preview URL?
Provision a server and create a project with visibility: "public" and lifecycle: "ephemeral", start a build, and print project.url. On PR close, call project.cleanup() to tear it down. The ephemeral lifecycle is what makes cleanup reliable.
Does it retry failed requests?
Yes. 429, 5xx, and network failures are retried automatically with exponential backoff. Tune it with { timeoutMs, maxRetries } per client.
Do I need the SDK if my agent already uses MCP?
No. If your agent does its own tool-calling, connect it to the MCP server. The SDK is for the code around the agent, which in this case is your CI pipeline.
Related reading
- The Server4Agent Python SDK: provision and orchestrate agent servers
- A typed Go client for agent servers, generated from our OpenAPI spec
- Why every AI agent should be able to return a URL
- From prompt to live URL: uptime monitor
- SDK documentation