The Server4Agent TypeScript SDK: a live preview URL for every pull request

A build-in-public walkthrough of the official Server4Agent TypeScript SDK: wire up CI so every pull request gets a real, disposable preview URL, then tears itself down when the PR closes.

Jul 13, 20269 min read

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, have the on-server agent build the branch inside an ephemeral project, start the app, and deploy it 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 22 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

bash
npm install @server4agent/sdk

It is ESM, targets Node 22 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:

ts
// 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_NUMBER: pr, PR_BRANCH: branch, REPO_URL: repo } = process.env;

const server = await client.servers.create({ task: `pr-${pr}` });
const project = await server.projects.create({ name: `pr-${pr}`, lifecycle: "ephemeral" });

// The on-server agent checks out the branch and builds it in the project's folder.
const task = await server.tasks.create(
  `Clone ${repo} at branch ${branch} into this folder, install its dependencies and build it. Do not start it.`,
  { projectId: project.id },
);
await task.wait({ timeoutMs: 900_000 });

// Run the app on the project's port, then put it online.
await project.start("npm start");
const { url } = await project.deploy();

console.log(`Preview: ${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.

Start it, then deploy it

A project's URL serves whatever is listening on that project's port, which is why the agent works in the project's folder (projectId) rather than at the server root. project.start() runs the app there with PORT set to the project's port and keeps it running across restarts. project.deploy() then checks that something is listening, makes the project public, and waits for the URL to answer, resolving to { url, status } where status is "live" or, if the URL had not answered yet, "publishing".

Deploy builds and starts nothing. Call it with no app running and it fails with a ConflictError (a 409, not_listening), so a preview job that skips start() fails loudly instead of posting a dead link. When the app itself is the problem, start() fails with a ConflictError too, and err.body.log_tail holds the app's last output, which is exactly what you want in the PR comment.

Handles keep the CI script short

Notice that server.projects.create() and server.tasks.create() are called on the server handle, and start() and deploy() on the project 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:

ts
const server = await client.servers.get("srv_abc");
await server.tasks.create("...");        // scoped to this server
await server.files.write("notes.txt", "...");
await server.refresh();                  // re-fetch in place

const project = await client.projects.get("prj_xyz");
await project.start("npm start");        // run its app on the project's port
await project.deploy();                  // then put it online

In a CI script, where you often want to log the URL, poll the agent's work, and later clean up, this is the difference between a readable file and a tangle of IDs.

Watching the agent work

task.wait() polls until the task reaches a terminal state, and it accepts tuning so a long install does not look hung:

ts
await task.wait({ pollIntervalMs: 3_000, timeoutMs: 900_000 });

if (task.status !== "completed") {
  // failed, cancelled, or awaiting_input: post it back to the PR and bail out.
  throw new Error(`Preview build ${task.status}: ${task.question ?? task.result ?? ""}`);
}

await project.start("npm start");
const { url } = await project.deploy();
console.log("Preview:", url, "changed:", task.change?.files);

Because the finished task says what it changed and deploy() resolves to the URL, 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:

ts
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:

ts
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:

ts
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 on-server agent checks out the branch and builds it in the project's folder, then CI starts the app and deploys it.
  • CI comments the live *.apps.server4agent.com URL 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 22 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, create a project with lifecycle: "ephemeral", and run a task in its folder (projectId) that builds the branch. Then project.start() the app and project.deploy() it, which makes the project public and resolves to its 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

External references

Questions, or a tool you want built?

Ask us about this post, or tell us what you would hand to an assistant first. A person reads every message and answers within one business day.