SDKs
Typed clients for the code around your agent: your backend, a CI job, a webhook receiver, an admin script. The hand-written JavaScript/TypeScript and Python clients share the same methods and shapes; a typed Go client is also generated from the OpenAPI spec.
Every example below switches together, and stays put on your next visit.
Command line
The clients below all read a sk_live_ key. The quickest way to get one onto a machine is to sign in from a browser, which puts an ordinary API key in ~/.server4agent/credentials.json with mode 0600.
npx @server4agent/cli login # approve in the browser
npx @server4agent/cli login --no-browser # print the URL instead (SSH)
npx @server4agent/cli whoami # which account is this?
npx @server4agent/cli logout # forget the key on this machinelogout only forgets it locally. In CI, skip the sign-in and set SERVER4AGENT_API_KEY, which takes precedence over anything stored.Install
The JS client is ESM, Node 22+, and ships its own types. The Python client is sync and async, supports 3.11+, and depends only on httpx. The Go client is generated from the OpenAPI spec and needs Go 1.26+.
npm install @server4agent/sdkConfigure the client
The Python and JavaScript clients read your key from the SERVER4AGENT_API_KEY environment variable if you don't pass one explicitly, and let you set a per-request timeout and the retry count. The Go client authenticates differently: put the key on a context value and pass that context to every call. This key is a sk_live_ secret: keep it server-side, never ship it in a browser bundle.
import { Server4Agent } from "@server4agent/sdk";
// With no arguments, reads SERVER4AGENT_API_KEY from the environment.
const client = new Server4Agent();
// Or pass the key and tune the transport explicitly.
const tuned = new Server4Agent({
apiKey: process.env.SERVER4AGENT_API_KEY,
timeoutMs: 30_000, // per-request timeout (default 60_000)
maxRetries: 4, // retries on 429/5xx/network (default 2)
});Quickstart
Provision a server, hand the on-server agent a goal, and wait for a live URL. Every method maps 1:1 to a REST endpoint; the SDK just gives it a type.
import { Server4Agent } from "@server4agent/sdk";
const client = new Server4Agent();
// create() resolves to a handle you can act on directly.
const server = await client.servers.create({ tier: "small" });
// Hand the on-server agent a goal and block until it's live.
const build = await server.builds.start("a FastAPI todo API with a small web UI");
await build.wait({ onProgress: (b) => console.log("build:", b.status) });
console.log("live at", build.url);Handles
create() and get() return rich handles: data records you can also act on. A Server handle exposes sub-resources (tasks, builds, files, projects, secrets) already scoped to it, so you never pass its id twice. A Project handle has its own secrets and acts on its app: start(), deploy(), logs(), export(). Every method also has a flat form on the client if you'd rather pass ids around.
// create() and get() both resolve to a Server handle.
const server = await client.servers.get("srv_abc");
// Its sub-resources are already scoped to it: no id to pass again.
await server.tasks.create("Summarize today's sales into report.md");
await server.files.write("notes.txt", "hello");
await server.secrets.set([{ key: "API_TOKEN", value: "tok_..." }]);
await server.exec("ls -la");
await server.refresh(); // re-fetch the record in place
// A Project handle acts on one app: start it, then put it online.
const project = await client.projects.get("prj_xyz");
await project.start("node server.js");
await project.deploy();
// Prefer passing ids around? The flat API takes a server id up front.
await client.tasks.create(server.id, "Same task, id passed explicitly.");
await client.files.write(server.id, "notes.txt", "hello");Servers
A server is a sized compute box. tier picks the size; the task field is just a human-readable label. Create, list, get, control the lifecycle, and delete.
// tier sizes the box: "small" | "medium" | "large". task is a label.
const server = await client.servers.create({ tier: "medium", task: "data pipeline" });
const all = await client.servers.list();
const one = await client.servers.get(server.id);
// Lifecycle controls.
await server.stop();
await server.start();
await server.restart();
// Rename, re-fetch, tear down.
await server.update({ task: "renamed pipeline" });
await server.refresh();
await server.delete();Run commands
exec() runs a shell command on the server and resolves to its stdout, handy for installing packages, running scripts, or inspecting the workspace.
// exec() runs a shell command on the server and resolves to stdout.
await server.exec("pip install httpx");
const out = await server.exec("python -c 'import sys; print(sys.version)'");
console.log(out);Files
Read, write, list, and delete files in the server's workspace. The workspace is durable: files and installed dependencies persist between tasks and builds.
// The workspace is durable: files and installed deps survive between tasks.
await server.files.write("app.py", "print('hello from the server')");
const listing = await server.files.list(); // string[] of paths
const content = await server.files.read("app.py");
await server.files.delete("app.py");Tasks
A task hands the on-server agent a single goal. wait() polls until it reaches a terminal state; pass a progress callback (onProgress in JS, on_poll in Python) to stream status to a log or UI. A task can finish as completed, failed, cancelled, or awaiting_input when the agent needs a decision from you.
// A task hands the on-server agent a goal. wait() polls until it settles.
const task = await server.tasks.create("Scrape today's HN front page to hn.json.");
await task.wait({ onProgress: (t) => console.log("task:", t.status) });
// Terminal states: completed, failed, cancelled, awaiting_input.
if (task.status === "completed") {
console.log(task.result);
} else if (task.status === "awaiting_input") {
// The agent needs a decision from you before it can continue.
console.log("agent asks:", task.question);
}Tuning runs, which fix an agent from an evaluation platform's report, work the same way: start one with client.tuning.start(), then call wait() on the session it returns. Reading a session is what moves it along, so wait() keeps polling until the fix is reported or has failed.
Builds
For a whole project, builds.start() runs an ordered pipeline (plan, scaffold, implement, install, build, deploy) on the server. The build handle carries per-step status, the produced artifacts, and the final live url.
// A build runs an ordered pipeline: plan, scaffold, implement, install,
// build, deploy. Stream step-by-step progress through onProgress.
const build = await server.builds.start("Build a SaaS dashboard with auth and deploy it.");
await build.wait({
timeoutMs: 900_000,
onProgress: (b) => {
const step = b.steps.find((s) => s.status === "running");
console.log("building:", step?.label ?? b.status);
},
});
console.log("live at", build.url);
console.log("artifacts:", build.artifacts); // e.g. ["app/page.tsx", "package.json"]Projects & deploys
A project is a workspace and app inside a server, with its own slug and public URL. New projects are private: their url stays null until you flip visibility to public, and the slug and URL survive visibility toggles. Start from a template (list them with templates.list()) and mark short-lived ones ephemeral so they clean up. A public URL serves only while the project's app is running, so a deploy comes after start().
// A project is a workspace + app inside a server. New projects are PRIVATE:
// url is null until you publish. Start from a template if you like.
const templates = await client.templates.list();
const project = await server.projects.create({
name: "status-page",
template: "dashboard",
// visibility defaults to "private"; lifecycle defaults to "persistent".
});
console.log(project.url); // null while private
// Publish it. The slug and URL stay stable across visibility toggles.
await project.update({ visibility: "public" });
await project.refresh();
console.log(project.url); // https://<slug>.apps.server4agent.com
// Live, but only for your team: opening it requires a Server4Agent sign-in
// and membership of this account. Defaults to "public", anyone with the link.
await project.update({ access: "team" });
// Or behind a password you hand out: no account needed to get in. It is
// stored encrypted, and setting a new one signs out everyone who used the old.
await project.update({ access: "password", accessPassword: "a long shared passphrase" });
// The URL answers only while an app listens on the project's port: start
// the app, then deploy it (more under "Start and deploy an app").
await project.start("node server.js");
const { url, status } = await project.deploy(); // "live" | "publishing"
const history = await server.deployments();
// Ephemeral projects clean up with cleanup(); persistent ones with delete().
await project.cleanup();Start and deploy an app
Deploy builds and starts nothing. Write the app into the project's folder (<slug>/ in the server's workspace), run it with project.start(), which sets PORT to the project's port and brings the process back after a restart, then call project.deploy(). Deploy checks that something is listening, makes the project public, and waits for its URL to answer. With nothing running it fails with a 409 (not_listening) and leaves the project as it was. server.deploy() does the same by project id and resolves to the URL.
import { ConflictError } from "@server4agent/sdk";
// A project's files live under <slug>/ in the server's workspace.
const project = await server.projects.create({ name: "hello" });
await server.files.write(
`${project.slug}/server.js`,
`require("node:http").createServer((req, res) => res.end("hi")).listen(process.env.PORT);`,
);
// start() runs it on the project's port (PORT) and keeps it running across
// restarts. An app that exits first fails with a ConflictError.
try {
await project.start("node server.js");
} catch (err) {
if (err instanceof ConflictError) console.error(err.body?.log_tail); // why it exited
throw err;
}
// deploy() builds and starts nothing: it checks something is listening,
// makes the project public, and waits for the URL to answer.
const { url, status } = await project.deploy(); // "live" | "publishing"Rather have the on-server agent write and start it? Give the task the project (projectId / project_id on tasks.create()) so it works in that folder, ask it to serve the app on $PORT, wait for it, then deploy. Calling start() again restarts the app, which is how changed code goes live; give a worker its own name to run it beside the web app.
Logs
Every long-running process a project starts writes to a log file, and logs() returns the newest lines of each: the first place to look when an app will not start. It returns null (None) until the server has reported any. When the server cannot be reached (starting, restarting, or paused) the API answers with the last saved tails without saying so, so check updatedAt before you read a tail as what is running now.
// The newest lines of each log file the project's processes write.
const logs = await project.logs({ lines: 50 }); // or server.logs({ projectId, lines: 50 })
if (logs) {
console.log("captured at", logs.updatedAt); // may be an older saved copy
for (const file of logs.files) console.log(file.path, file.tail);
}Secrets
Secrets are environment variables, bound server-wide (every project on the server sees them) or to one project. Values are write-only: a read returns the key, a note, a masked hint, and where it is bound, never the value. A key holds one value per account, so setting it again changes it everywhere it is bound. servers.create() and projects.create() take secrets inline too.
// Server-wide: every project on this server sees it.
await server.secrets.set([{ key: "DATABASE_URL", value: process.env.DATABASE_URL! }]);
// One project only. Its list() shows its own plus the server's.
await project.secrets.set([{ key: "SESSION_SECRET", value: process.env.SESSION_SECRET! }]);
const seen = await project.secrets.list();
// Account level: create or update by key, without binding it anywhere new.
await client.secrets.set({ key: "STRIPE_KEY", value: "rk_test_...", description: "test mode" });
// Keys, masked hints, and bindings. Never a value.
const all = await client.secrets.list();
await client.secrets.delete(all[0].id); // and every binding it hadMove a project
project.export() packs a project (its source, settings, run plan, and the names of the variables it reads) into one file behind a time-limited link. The link needs no credentials, so treat it like the code itself. Import the file on any server, in this account or another, with server.projects.import() (import_bundle() in Python). The copy lands private with its own slug and starts bringing itself up straight away. A bundle never carries secret values, so the result lists the ones still needed in missingEnv; if bring-up stops to wait for them, init() resumes it once they are set.
import { Server4Agent } from "@server4agent/sdk";
// Pack the project into one file behind a time-limited link.
const bundle = await project.export({ ttlSeconds: 3600 });
const bytes = new Uint8Array(await (await fetch(bundle.url)).arrayBuffer());
// Import it on any server. A client holding another account's key moves it
// into that account.
const dest = new Server4Agent({ apiKey: process.env.DEST_API_KEY });
const target = await dest.servers.get("srv_target");
const { project: copy, missingEnv } = await target.projects.import(bytes, { name: "status-page" });
// Values never travel in a bundle: supply them on the other side.
await copy.secrets.set(missingEnv.map((key) => ({ key, value: process.env[key]! })));Account & budget
client.me() reports the account a key acts on, its plan, its spend against its budget this billing period, and how many servers, projects, and keys it holds. usage.blocked is true while new tasks are refused, and usage.reason says which ceiling. Check it before a long run rather than finding the ceiling halfway through with a 402. It needs an account-wide key.
const me = await client.me();
console.log(me.account.plan, me.usage.spendUsd, me.usage.hardLimitUsd);
// Check before a long run rather than meeting the ceiling halfway through.
if (me.usage.blocked) {
throw new Error(`New tasks are paused: ${me.usage.reason}`); // "budget_cap" | "credits_exhausted"
}Errors & retries
Every failure is a Server4AgentError (JS) or subclass of it (Python), so you can catch broadly or by type: NotFoundError, RateLimitError, APIStatusError. Each carries the HTTP status, a machine-readable code, the message, and a request id to quote to support. Retryable failures (429, 5xx, network blips) are retried automatically with exponential backoff; tune the count with maxRetries / max_retries.
import {
Server4AgentError,
NotFoundError,
RateLimitError,
APIStatusError,
} from "@server4agent/sdk";
try {
await client.servers.get("srv_does_not_exist");
} catch (err) {
if (err instanceof NotFoundError) {
// 404
} 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);
} else if (err instanceof Server4AgentError) {
// anything else from the SDK (timeouts, network)
}
}Async (Python)
Python ships a second client, AsyncServer4Agent, that mirrors the sync one method-for-method: same names, same shapes, every call awaited. The JS client is already promise-based, so there's nothing extra to import.
// The JS client is already promise-based: every method returns a Promise,
// so there's no separate async client. Just await.
const server = await client.servers.create({ tier: "small" });
const task = await server.tasks.create("Summarize today's top HN posts to summary.md");
await task.wait();
console.log(task.result);Verifying webhooks
The Python and JavaScript clients export a verify_webhook_signature /verifyWebhookSignature helper that checks the Server4Agent-Signature header against your webhook's secret (from Go, verify it yourself). Always verify against the raw request body, before you parse it as JSON. See Webhooks for the event catalog and how to subscribe.
import { verifyWebhookSignature } from "@server4agent/sdk";
// req.text() must be the RAW body. Verify before you JSON.parse it:
// re-serializing changes the bytes and breaks the signature.
const raw = await req.text();
const ok = await verifyWebhookSignature(
process.env.SERVER4AGENT_WEBHOOK_SECRET!,
raw,
req.headers.get("server4agent-signature"),
);
if (!ok) return new Response("invalid signature", { status: 401 });
const event = JSON.parse(raw);Recipes
Ephemeral preview environments in CI
Provision a real, disposable server per pull request, deploy the branch to it, and clean it up when the PR closes.
// Run from CI on every PR: give it a real, disposable URL, then tear it
// down when the PR closes. lifecycle "ephemeral" marks it 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 agent works in the project's folder, where the URL's app has to run.
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 });
// Start 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();A webhook receiver that reacts to deployments
Subscribe once (see Webhooks), then verify and act on each delivery.
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);
if (event.event === "deployment.live") {
await notifySlack(`Deployed: ${event.url}`);
}
res.status(200).end();
});Per-customer workspaces from your own product
If a customer action should provision a real environment, that's your backend calling the SDK directly, no agent tool-calling loop involved.
// Your product's backend, not an agent: a customer submits a request, your
// own business logic decides what to build, and you provision a real
// workspace for them on demand.
import { Server4Agent } from "@server4agent/sdk";
const client = new Server4Agent();
export async function provisionForCustomer(customerId: string, goal: string) {
const server = await client.servers.create({ task: `customer-${customerId}` });
const project = await server.projects.create({
name: `${customerId}-workspace`,
visibility: "public",
template: "dashboard",
});
await server.tasks.create(goal, { projectId: project.id }); // works in its folder
return project.url;
}Scoping a key before handing it to another tool
Mint a key restricted to one server (or project) instead of sharing your account-wide key.
// Hand a support tool a key that can only touch one customer's server,
// not your whole account.
const supportKey = await client.keys.create("support-tool");
await client.keys.updateScope(supportKey.id, {
allowedServerIds: [server.id],
});
// supportKey.key is the plaintext, shown once, here. Hand it to the tool
// and store the id if you'll need to revoke it later.
// await client.keys.revoke(supportKey.id);About the Go client
Flip the language switch to Go and most examples above rewrite themselves in Go. A few conventions are worth calling out, because the Go client is generated from the OpenAPI spec rather than hand-written like the Python and JS clients:
- Auth is a
contextvalue, not a constructor argument, so set yoursk_live_key as a bearer token and pass the context to every call. - There are no handles. Every call is a per-resource service (
client.ServersAPI,client.TasksAPI, …) that takes the ids explicitly and returns the model, the*http.Response, and an error. - No
wait(): pollGetTask/GetBuilduntil the status is terminal. - Optional fields are pointers; the
server4agent.PtrStringhelpers and typed enum constants (SERVERTIER_SMALL) fill them in.
package main
import (
"context"
"fmt"
"os"
server4agent "github.com/Server4Agent/server4agent-go"
)
func main() {
client := server4agent.NewAPIClient(server4agent.NewConfiguration())
// Your sk_live_ key as a bearer token.
ctx := context.WithValue(context.Background(),
server4agent.ContextAccessToken, os.Getenv("SERVER4AGENT_API_KEY"))
tier := server4agent.SERVERTIER_SMALL
server, _, err := client.ServersAPI.CreateServer(ctx).
ServerCreate(server4agent.ServerCreate{Tier: &tier}).
Execute()
if err != nil {
panic(err)
}
fmt.Println("server", server.GetId(), server.GetStatus())
}Method and model docs are generated in the server4agent-go repo, and every call maps to the API reference.
Source
The SDKs are open source, one public repo per language: server4agent-python, server4agent-js, and server4agent-go. The Python and JS repos ship runnable examples and a full test suite. Every method wraps one of the endpoints on the REST API page.
Next: Webhooks→Connect an agent
Create a key, paste the MCP config, and give your agent a persistent server it can build on.