temperDocs
Menu

Getting started

Quickstart

From an API key to an end user's agent computer with Gmail connected, an approval policy, and the audit log.

This walkthrough takes you from an API key to a running environment for one end user: run a command, store a model API key as a secret, connect the user's Gmail, set an approval policy and approve an action, then read the audit log and usage. Every step has TypeScript, Python and Go code you can copy and run.

If you have not read it yet, the introduction explains environments, connections, secrets, policies and the audit log in a few paragraphs.

0. Before you start

Get an API key

Create an API key in the console, on the Team page (owners only). The key (temper_sk_...) is shown once; Temper stores only a hash of it, so copy it somewhere safe. See team and keys.

Set it as an environment variable, together with the API address. All three SDKs read both variables:

bash
export TEMPER_API_KEY=temper_sk_...
export TEMPER_BASE_URL=https://api.temper.im

Install the SDK

bash
# TypeScript (Node 22 or later)
npm install @temper-hq/sdk

# Python 3.10 or later
pip install temper-hq

# Go 1.21 or later, inside your module
go get github.com/temper-hq/sdk-go

Save each TypeScript example as example.mts and run it with node example.mts, each Python example as example.py and run it with python example.py. Each Go example is a complete package main; put it in its own directory and run it with go run ..

1. Create an environment for an end user

end_user_id is your own identifier for the user. Each end user has at most one environment that has not been destroyed; creating a second one fails with 409 environment_exists, which carries the existing environment's id, and this example reuses that one. To retry a create safely after a timeout, pass an idempotency key; to give the agent non-secret settings, pass env and files. See Retrying safely and Configuration.

import { Temper, isTemperError } from "@temper-hq/sdk";

const temper = new Temper(); // reads TEMPER_API_KEY and TEMPER_BASE_URL
const endUser = "alice";

let env;
try {
  env = await temper.environments.create({ end_user_id: endUser, resources: { cpus: 2, memory_mib: 4096 } });
} catch (err) {
  if (!isTemperError(err, "environment_exists")) throw err;
  env = await temper.environments.get(err.environmentId!);
}
console.log(`environment ${env!.id}: desired ${env!.desired_state}, actual ${env!.state}`);
console.log(`ENV_ID=${env!.id}`);
from temper_hq import Temper, TemperError

temper = Temper()  # reads TEMPER_API_KEY and TEMPER_BASE_URL
end_user = "alice"

try:
    env = temper.environments.create(end_user_id=end_user, resources={"cpus": 2, "memory_mib": 4096})
except TemperError as e:
    if e.code != "environment_exists":
        raise
    env = temper.environments.get(e.environment_id)
print(f"environment {env.id}: desired {env.desired_state}, actual {env.state}")
print(f"ENV_ID={env.id}")
package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New() // reads TEMPER_API_KEY and TEMPER_BASE_URL
	if err != nil {
		log.Fatal(err)
	}
	endUser := "alice"

	env, err := client.Environments.Create(ctx, temper.CreateEnvironmentParams{
		EndUserID: endUser,
		Resources: &temper.ResourcesInput{CPUs: 2, MemoryMiB: 4096},
	})
	var exists *temper.Error
	if errors.As(err, &exists) && exists.Code == temper.CodeEnvironmentExists {
		env, err = client.Environments.Get(ctx, exists.EnvironmentID)
	}
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("environment %s: desired %s, actual %s\n", env.ID, env.DesiredState, env.State)
	fmt.Printf("ENV_ID=%s\n", env.ID)
}

A new environment has desired_state = running and state = pending. Once it is placed on a host and the VM is up, state becomes running. By default an environment is suspended after 6 hours idle and stopped after 7 days suspended; the next call wakes it up. You can change these thresholds with environments.updateIdle (Python: update_idle, Go: Environments.UpdateIdle). See environments.

bash
export ENV_ID=env_...   # the ENV_ID printed above

2. Run a command, read and write files

Commands run as the agent user inside the VM, with the same permissions and the same egress allowlist as your agent. If the environment is not running, the call wakes it first and waits for it. A non-zero exit code is a normal result: check exit_code.

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();
const envId = process.env.ENV_ID!;

const r = await temper.environments.exec(envId, { command: "python3 --version && whoami", timeout_secs: 30 });
console.log(r.exit_code, r.stdout.trim());

await temper.files.write(envId, "notes/todo.md", "# Today\n- Reply to email\n", { mkdir: true });
console.log(await temper.files.readText(envId, "notes/todo.md"));
for (const entry of (await temper.files.list(envId, "notes")).entries) console.log(entry.type, entry.name);
import os

from temper_hq import Temper

temper = Temper()
env_id = os.environ["ENV_ID"]

r = temper.environments.exec(env_id, command="python3 --version && whoami", timeout_secs=30)
print(r.exit_code, r.stdout.strip())

temper.files.write_text(env_id, "notes/todo.md", "# Today\n- Reply to email\n", mkdir=True)
print(temper.files.read_text(env_id, "notes/todo.md"))
for entry in temper.files.list(env_id, "notes").entries:
    print(entry.type, entry.name)
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	envID := os.Getenv("ENV_ID")

	r, err := client.Environments.Exec(ctx, envID, temper.ExecParams{Command: "python3 --version && whoami", TimeoutSecs: 30})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(*r.ExitCode, strings.TrimSpace(r.Stdout))

	if _, err := client.Files.Write(ctx, envID, "notes/todo.md", strings.NewReader("# Today\n- Reply to email\n"), true); err != nil {
		log.Fatal(err)
	}
	b, err := client.Files.Read(ctx, envID, "notes/todo.md")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Print(string(b))
	listing, err := client.Files.List(ctx, envID, "notes")
	if err != nil {
		log.Fatal(err)
	}
	for _, e := range listing.Entries {
		fmt.Println(e.Type, e.Name)
	}
}

This prints the Python version, agent, the file contents and the directory listing. Relative paths are resolved against the agent's home directory. For long-running work, start a background process with processes.spawn and follow its output with processes.followOutput (Python: follow_output, Go: Processes.Follow). See the exec API.

3. Store your model API key as a secret

Do not put model API keys or internal service tokens in your agent package or in the environment's files: a prompt-injected agent could read them and send them elsewhere. Store them as secrets instead. The real value lives only in the control plane (encrypted) and in the credential gateway on the host. The VM gets a stand-in token, which the gateway swaps for the real value only on requests to the hosts you list.

Secret names follow environment-variable rules: uppercase letters, digits and underscores only (^[A-Z][A-Z0-9_]{0,63}$). The stand-in file in the VM has the same name, for example /run/temper/secrets/OPENROUTER_API_KEY.

A standard secret goes through your approval policy when it is used (step 5 allows this one). A token for your own infrastructure, such as one your agent uses to call your backend, can be stored with kind: "infrastructure" instead: it skips the policy and is still audited. See Infrastructure secrets.

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();

// Developer-level: available in every environment. Store a per-user key with secrets.setForEnvironment instead.
const secret = await temper.secrets.set("OPENROUTER_API_KEY", {
  value: process.env.MODEL_API_KEY!,
  hosts: ["openrouter.ai"], // only sent to these hosts; by default replaces the authorization header, prefix "Bearer "
});
console.log(`stored ${secret.name} (${secret.hosts.join(", ")}); the value is never returned`);

// Extra hosts this environment may reach (the secret's hosts are already allowed). Takes effect without replacing the VM.
const egress = await temper.secrets.setEnvironmentEgress(process.env.ENV_ID!, ["pypi.org", "*.pythonhosted.org"]);
console.log("egress allowlist:", egress.allow.join(", "));
import os

from temper_hq import Temper

temper = Temper()

# Developer-level: available in every environment. Store a per-user key with secrets.set_for_environment instead.
secret = temper.secrets.set(
    "OPENROUTER_API_KEY",
    value=os.environ["MODEL_API_KEY"],
    hosts=["openrouter.ai"],  # only sent to these hosts; by default replaces the authorization header, prefix "Bearer "
)
print(f"stored {secret.name} ({', '.join(secret.hosts)}); the value is never returned")

# Extra hosts this environment may reach (the secret's hosts are already allowed). Takes effect without replacing the VM.
egress = temper.secrets.set_environment_egress(os.environ["ENV_ID"], ["pypi.org", "*.pythonhosted.org"])
print("egress allowlist:", ", ".join(egress.allow))
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"strings"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}

	// Developer-level: available in every environment. Store a per-user key with Secrets.SetForEnvironment instead.
	secret, err := client.Secrets.Set(ctx, "OPENROUTER_API_KEY", temper.PutSecretParams{
		Value: os.Getenv("MODEL_API_KEY"),
		Hosts: []string{"openrouter.ai"}, // only sent to these hosts; by default replaces the authorization header, prefix "Bearer "
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("stored %s (%s); the value is never returned\n", secret.Name, strings.Join(secret.Hosts, ", "))

	// Extra hosts this environment may reach (the secret's hosts are already allowed). Takes effect without replacing the VM.
	egress, err := client.Secrets.SetEnvironmentEgress(ctx, os.Getenv("ENV_ID"), []string{"pypi.org", "*.pythonhosted.org"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("egress allowlist:", strings.Join(egress.Allow, ", "))
}

Inside the VM, your agent reads the stand-in (tmpr_...) from /run/temper/secrets/OPENROUTER_API_KEY and uses it exactly like the real key. The gateway replaces it with the real value when the request goes to openrouter.ai. Sent anywhere else, it is not replaced, so a stolen stand-in is useless:

bash
# Inside the VM (your agent's code)
export OPENROUTER_API_KEY=$(cat /run/temper/secrets/OPENROUTER_API_KEY)
curl -s https://openrouter.ai/api/v1/models -H "Authorization: Bearer $OPENROUTER_API_KEY" | head -c 200

More in credentials, egress and the model API key guide.

4. Connect the end user's Gmail

The end user authorizes once on Google's consent screen. Temper stores the tokens encrypted; the built-in tools in the VM (temper-gmail and others) call Gmail through the credential gateway. The agent never gets the token, and verification codes and password-reset links are filtered out of the mail it reads.

First register your own Google OAuth app, so the consent screen shows your app's name (see use your own OAuth app). Add the returned redirect_uri to the app's authorized redirect URIs in the Google Cloud Console. Then create an authorization link for the end user:

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();

const app = await temper.connections.setOAuthClient("google", {
  client_id: process.env.GOOGLE_OAUTH_CLIENT_ID!,
  client_secret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
});
console.log("register this redirect URI with Google:", app.redirect_uri);

const link = await temper.connections.authorize({
  end_user_id: "alice",
  connectors: ["gmail", "calendar"],
  return_url: "http://localhost:3000/connected",
});
console.log(`send the end user here (single use, valid until ${link.expires_at}):`);
console.log(`AUTH_URL=${link.url}`);
import os

from temper_hq import Temper

temper = Temper()

app = temper.connections.set_oauth_client(
    "google",
    client_id=os.environ["GOOGLE_OAUTH_CLIENT_ID"],
    client_secret=os.environ["GOOGLE_OAUTH_CLIENT_SECRET"],
)
print("register this redirect URI with Google:", app.redirect_uri)

link = temper.connections.authorize(
    end_user_id="alice", connectors=["gmail", "calendar"], return_url="http://localhost:3000/connected"
)
print(f"send the end user here (single use, valid until {link.expires_at}):")
print(f"AUTH_URL={link.url}")
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}

	app, err := client.Connections.SetOAuthClient(ctx, "google", os.Getenv("GOOGLE_OAUTH_CLIENT_ID"), os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET"))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("register this redirect URI with Google:", app.RedirectURI)

	link, err := client.Connections.Authorize(ctx, temper.AuthorizeConnectionParams{
		EndUserID:  "alice",
		Connectors: []string{"gmail", "calendar"},
		ReturnURL:  "http://localhost:3000/connected",
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("send the end user here (single use, valid until %s):\n", link.ExpiresAt)
	fmt.Printf("AUTH_URL=%s\n", link.URL)
}

After the user consents, Temper exchanges the code for tokens and redirects to your return_url with the result:

  • Success: http://localhost:3000/connected?status=ok&connection_id=conn_...
  • Failure (the user declined, or the token exchange failed): ...?status=error&error=access_denied

You can list connections at any time, and revoke one (revoking also revokes the grant on Google's side):

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();
const { data } = await temper.connections.list({ end_user_id: "alice" });
for (const c of data) console.log(c.id, c.provider, c.account, c.connectors.join(","), c.status);
console.log(`alice has ${data.length} connection(s)`);
// To revoke: await temper.connections.revoke(c.id);
from temper_hq import Temper

temper = Temper()
conns = temper.connections.list(end_user_id="alice")
for c in conns.data:
    print(c.id, c.provider, c.account, ",".join(c.connectors), c.status)
print(f"alice has {len(conns.data)} connection(s)")
# To revoke: temper.connections.revoke(c.id)
package main

import (
	"context"
	"fmt"
	"log"
	"strings"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	conns, err := client.Connections.List(ctx, "alice", false)
	if err != nil {
		log.Fatal(err)
	}
	for _, c := range conns.Data {
		account := ""
		if c.Account != nil {
			account = *c.Account
		}
		fmt.Println(c.ID, c.Provider, account, strings.Join(c.Connectors, ","), c.Status)
	}
	fmt.Printf("alice has %d connection(s)\n", len(conns.Data))
	// To revoke: client.Connections.Revoke(ctx, c.ID)
}

A healthy connection is active. A status of error means Google rejected the refresh token (for example, the user revoked access on Google's side), and the user needs to authorize again.

5. Set an approval policy and handle approval webhooks

When the agent tries an action such as sending an email, the credential gateway asks Temper whether to allow it, deny it, or ask the end user. To ask, Temper sends an approval.requested webhook to your backend; you show the request to the user and send their decision back. See approvals and the approval webhooks guide.

Set the webhook URL

An owner sets the webhook URL in the console, on the Team page. Use an https:// URL whose certificate is signed by a public CA, such as https://api.your-app.com/webhooks/temper. Temper does not follow redirects (a 3xx counts as a failed delivery), so use the final URL.

The first time you set the URL, change it, or rotate the secret, the console shows a new signing secret (whsec_...) once. Store it on your backend:

bash
export TEMPER_WEBHOOK_SECRET=whsec_...

Your backend: verify the signature and approve

Every delivery carries a Temper-Signature header. Always verify it against the raw request body, before parsing it. This example approves each request "just this once"; a real backend shows the summary to the end user and approves or denies based on their choice.

import { createServer } from "node:http";
import { Temper, verifyWebhook } from "@temper-hq/sdk";

const temper = new Temper();
const secret = process.env.TEMPER_WEBHOOK_SECRET!;
const port = Number(process.env.WEBHOOK_PORT ?? 8787);

createServer(async (req, res) => {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  try {
    const event = await verifyWebhook(secret, Buffer.concat(chunks), req.headers["temper-signature"] as string);
    res.writeHead(200).end();
    if (event.type === "approval.requested") {
      const { approval_id, connector, category, summary } = event.data as Record<string, string>;
      console.log(`approval request ${approval_id}: ${connector} ${category} ${summary}`);
      const decision = await temper.approvals.approve(approval_id!, { kind: "once" });
      console.log(`approved ${approval_id} ${decision.status}`);
    } else {
      console.log(`event ${event.type}`);
    }
  } catch (err) {
    console.error("bad signature, rejecting:", err);
    res.writeHead(400).end();
  }
}).listen(port, "127.0.0.1", () => console.log(`listening on :${port}`));
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

from temper_hq import Temper, WebhookVerificationError, verify_webhook

temper = Temper()
secret = os.environ["TEMPER_WEBHOOK_SECRET"]
port = int(os.environ.get("WEBHOOK_PORT", "8787"))


class Handler(BaseHTTPRequestHandler):
    def do_POST(self) -> None:
        body = self.rfile.read(int(self.headers["Content-Length"]))
        try:
            event = verify_webhook(secret, body, self.headers.get("Temper-Signature"))
        except WebhookVerificationError as e:
            print("bad signature, rejecting:", e, flush=True)
            self.send_response(400)
            self.end_headers()
            return
        self.send_response(200)
        self.end_headers()
        if event.type == "approval.requested":
            d = event.data
            print(f"approval request {d['approval_id']}: {d['connector']} {d['category']} {d['summary']}", flush=True)
            decision = temper.approvals.approve(d["approval_id"], {"kind": "once"})
            print(f"approved {d['approval_id']} {decision.status}", flush=True)
        else:
            print(f"event {event.type} {json.dumps(event.data)}", flush=True)

    def log_message(self, *args: object) -> None:  # no access log
        pass


print(f"listening on :{port}", flush=True)
HTTPServer(("127.0.0.1", port), Handler).serve_forever()
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"time"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	secret := os.Getenv("TEMPER_WEBHOOK_SECRET")
	port := os.Getenv("WEBHOOK_PORT")
	if port == "" {
		port = "8787"
	}

	http.HandleFunc("/webhooks/temper", func(w http.ResponseWriter, r *http.Request) {
		body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		event, err := temper.VerifyWebhook(secret, body, r.Header.Get(temper.SignatureHeader), 0, time.Time{})
		if err != nil {
			log.Println("bad signature, rejecting:", err)
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusOK)
		if event.Type != "approval.requested" {
			fmt.Println("event", event.Type)
			return
		}
		var a temper.ApprovalRequested
		_ = json.Unmarshal(event.Data, &a)
		fmt.Printf("approval request %s: %s %s %s\n", a.ApprovalID, a.Connector, a.Category, a.Summary)
		decision, err := client.Approvals.Approve(context.Background(), a.ApprovalID, temper.OnceScope())
		if err != nil {
			log.Println(err)
			return
		}
		fmt.Printf("approved %s %s\n", a.ApprovalID, decision.Status)
	})
	fmt.Printf("listening on :%s\n", port)
	log.Fatal(http.ListenAndServe("127.0.0.1:"+port, nil))
}

These servers listen on 127.0.0.1; in production, put your webhook handler behind your public HTTPS endpoint.

Webhooks are delivered at least once. Only a 2xx response counts as delivered; anything else is retried with backoff for 24 hours. A retried event keeps the same id (also sent in the Temper-Event-Id header), so you can use it to deduplicate. If you would rather not run a webhook endpoint, poll approvals.list({ status: "pending" }) and decide with your API key.

Set the policy

Rules combine as deny over ask over allow. High-risk categories (send, pay, delete, change_permission) cannot be set to allow; such a rule is rejected with 400. max_scope is the widest grant the end user may give: once < task < session < time_limited < permanent.

An ask that matches always wins over an allow, so keep ask rules narrow: a bare { category: "write", effect: "ask" } would also match the agent's calls to your model provider (requests that use a secret are checked as connector secret:<NAME>, and a POST counts as write) and make every model call wait for approval. The policy below allows the model key outright and asks only for calendar writes. Actions that match no rule are denied, except high-risk ones, which are asked.

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();

// Developer default policy: applies to every environment that has no policy of its own.
await temper.policies.setDefault([
  { category: "read", effect: "allow" },
  { connector: "secret:OPENROUTER_API_KEY", effect: "allow" },
  { connector: "calendar", category: "write", effect: "ask", max_scope: "session" },
  { connector: "gmail", category: "send", effect: "ask", max_scope: "once" },
  { connector: "stripe", effect: "deny" },
]);
const effective = await temper.policies.getForEnvironment(process.env.ENV_ID!);
console.log(`${effective.environment_id} uses the ${effective.source} policy, ${effective.rules.length} rules`);
import os

from temper_hq import Temper

temper = Temper()

# Developer default policy: applies to every environment that has no policy of its own.
temper.policies.set_default([
    {"category": "read", "effect": "allow"},
    {"connector": "secret:OPENROUTER_API_KEY", "effect": "allow"},
    {"connector": "calendar", "category": "write", "effect": "ask", "max_scope": "session"},
    {"connector": "gmail", "category": "send", "effect": "ask", "max_scope": "once"},
    {"connector": "stripe", "effect": "deny"},
])
effective = temper.policies.get_for_environment(os.environ["ENV_ID"])
print(f"{effective.environment_id} uses the {effective.source} policy, {len(effective.rules)} rules")
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}

	// Developer default policy: applies to every environment that has no policy of its own.
	_, err = client.Policies.SetDefault(ctx, []temper.PolicyRule{
		{Category: "read", Effect: "allow"},
		{Connector: "secret:OPENROUTER_API_KEY", Effect: "allow"},
		{Connector: "calendar", Category: "write", Effect: "ask", MaxScope: "session"},
		{Connector: "gmail", Category: "send", Effect: "ask", MaxScope: "once"},
		{Connector: "stripe", Effect: "deny"},
	})
	if err != nil {
		log.Fatal(err)
	}
	effective, err := client.Policies.GetForEnvironment(ctx, os.Getenv("ENV_ID"))
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s uses the %s policy, %d rules\n", effective.EnvironmentID, effective.Source, len(effective.Rules))
}

Try it

When the agent in the VM sends an email with the built-in tool, the credential gateway asks for approval under the gmail / send rule above:

bash
# Inside the VM (your agent's code); needs an active Gmail connection from step 4
temper-gmail send --to bob@example.com --subject "Weekly sync" --body "Moving our weekly sync to Thursday."

Your webhook handler prints approval request apr_... and then approved apr_... approved, and the gateway lets the email go through. If the request is denied, the tool exits with code 4. List the approval records and any grants they left behind:

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();
const envId = process.env.ENV_ID!;
for await (const a of temper.approvals.iterate({ environment_id: envId })) {
  console.log(a.id, a.connector, a.category, a.status, a.scope ?? "-", a.decided_via ?? "-");
}
console.log(`${(await temper.approvals.listGrants({ environment_id: envId })).length} active grant(s) ("once" leaves no grant)`);
import os

from temper_hq import Temper

temper = Temper()
env_id = os.environ["ENV_ID"]
for a in temper.approvals.iter(environment_id=env_id):
    print(a.id, a.connector, a.category, a.status, a.scope or "-", a.decided_via or "-")
print(f"{len(temper.approvals.list_grants(environment_id=env_id))} active grant(s) (\"once\" leaves no grant)")
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	envID := os.Getenv("ENV_ID")
	it := client.Approvals.Iter(temper.ListApprovalsParams{EnvironmentID: envID})
	for it.Next(ctx) {
		a := it.Value()
		scope, via := "-", "-"
		if a.Scope != nil {
			scope = *a.Scope
		}
		if a.DecidedVia != nil {
			via = *a.DecidedVia
		}
		fmt.Println(a.ID, a.Connector, a.Category, a.Status, scope, via)
	}
	if err := it.Err(); err != nil {
		log.Fatal(err)
	}
	grants, err := client.Approvals.ListGrants(ctx, envID)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d active grant(s) (\"once\" leaves no grant)\n", len(grants))
}

When approving, you can give a wider scope, up to the rule's max_scope: sessionScope(approval) / taskScope(approval) (Python: session_scope / task_scope, Go: SessionScope / TaskScope), untilScope(date) (until_scope, UntilScope), or { kind: "permanent" } (Go: PermanentScope()). Any scope other than "once" leaves a grant: later actions of the same kind within its scope are allowed without asking. Revoke a grant with approvals.revokeGrant. A request that is not decided by its expires_at (10 minutes by default) counts as denied.

6. Read the audit log and usage

import { writeFile } from "node:fs/promises";
import { Temper } from "@temper-hq/sdk";

const temper = new Temper();
const envId = process.env.ENV_ID!;

const { data: chains, intact } = await temper.audit.chains(envId);
console.log(`${chains.length} hash chain(s), ${intact ? "all intact" : "a chain is broken!"}`);
let n = 0;
for await (const r of temper.audit.iterate(envId, { source: "egress" })) {
  if (n++ < 5) console.log(r.at, r.source, JSON.stringify(r.record));
}
console.log(`${n} egress record(s)`);

// Raw records (NDJSON) for recomputing the hash chain offline. At most 100000 lines per export; split larger ranges with from / to.
const res = await temper.audit.export(envId);
await writeFile(`audit-${envId}.jsonl`, await res.text());

const usage = await temper.metering.usage(); // defaults to the current month (UTC) so far
console.log("this month:", usage.total);
await temper.metering.setLimit({ metric: "egress_gib", monthly_limit: 50 }); // at most 50 GiB of egress per month across all environments
for (const l of await temper.metering.listLimits()) console.log(l.metric, `${l.used} / ${l.monthly_limit}`);
import os

from temper_hq import Temper

temper = Temper()
env_id = os.environ["ENV_ID"]

chains = temper.audit.chains(env_id)
print(f"{len(chains.data)} hash chain(s), {'all intact' if chains.intact else 'a chain is broken!'}")
records = list(temper.audit.iter(env_id, source="egress"))
for r in records[:5]:
    print(r.at, r.source, r.record)
print(f"{len(records)} egress record(s)")

# Raw records (NDJSON) for recomputing the hash chain offline. At most 100000 lines per export; split larger ranges with from_ / to.
with open(f"audit-{env_id}.jsonl", "w") as f:
    for line in temper.audit.iter_export(env_id):
        f.write(line + "\n")

usage = temper.metering.usage()  # defaults to the current month (UTC) so far
print("this month:", usage.total)
temper.metering.set_limit(metric="egress_gib", monthly_limit=50)  # at most 50 GiB of egress per month across all environments
for lim in temper.metering.list_limits():
    print(lim.metric, f"{lim.used} / {lim.monthly_limit}")
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"os"
	"time"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	envID := os.Getenv("ENV_ID")

	chains, err := client.Audit.Chains(ctx, envID)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d hash chain(s), intact: %v\n", len(chains.Data), chains.Intact)
	n := 0
	it := client.Audit.Iter(envID, temper.ListAuditParams{Source: "egress"})
	for it.Next(ctx) {
		if r := it.Value(); n < 5 {
			fmt.Println(r.At, r.Source, string(r.Record))
		}
		n++
	}
	if err := it.Err(); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%d egress record(s)\n", n)

	// Raw records (NDJSON) for recomputing the hash chain offline. At most 100000 lines per export; split larger ranges with from / to.
	export, err := client.Audit.Export(ctx, envID, time.Time{}, time.Time{})
	if err != nil {
		log.Fatal(err)
	}
	defer export.Close()
	f, err := os.Create("audit-" + envID + ".jsonl")
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()
	if _, err := io.Copy(f, export); err != nil {
		log.Fatal(err)
	}

	usage, err := client.Metering.Usage(ctx, temper.UsageParams{}) // defaults to the current month (UTC) so far
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("this month: %+v\n", usage.Total)
	// At most 50 GiB of egress per month across all environments
	if _, err := client.Metering.SetLimit(ctx, temper.SetLimitParams{Metric: temper.MetricEgressGiB, MonthlyLimit: 50}); err != nil {
		log.Fatal(err)
	}
	limits, err := client.Metering.ListLimits(ctx)
	if err != nil {
		log.Fatal(err)
	}
	for _, l := range limits {
		fmt.Printf("%s %v / %v\n", l.Metric, l.Used, l.MonthlyLimit)
	}
}

An environment that goes over a monthly limit is suspended (an environment.over_limit event records why). Resuming, waking and exec then fail with 409 limit_exceeded until you raise or remove the limit, or the next month starts. See audit and usage and limits.

7. Clean up

import { Temper } from "@temper-hq/sdk";

const temper = new Temper();
await temper.secrets.delete("OPENROUTER_API_KEY");
await temper.metering.deleteLimit({ metric: "egress_gib" });
await temper.policies.deleteDefault();
const env = await temper.environments.destroy(process.env.ENV_ID!); // deletes the data disk and backups
console.log(`${env.id} destroyed`);
import os

from temper_hq import Temper

temper = Temper()
temper.secrets.delete("OPENROUTER_API_KEY")
temper.metering.delete_limit(metric="egress_gib")
temper.policies.delete_default()
env = temper.environments.destroy(os.environ["ENV_ID"])  # deletes the data disk and backups
print(f"{env.id} destroyed")
package main

import (
	"context"
	"fmt"
	"log"
	"os"

	temper "github.com/temper-hq/sdk-go"
)

func main() {
	ctx := context.Background()
	client, err := temper.New()
	if err != nil {
		log.Fatal(err)
	}
	if err := client.Secrets.Delete(ctx, "OPENROUTER_API_KEY"); err != nil {
		log.Fatal(err)
	}
	if err := client.Metering.DeleteLimit(ctx, temper.MetricEgressGiB, ""); err != nil {
		log.Fatal(err)
	}
	if err := client.Policies.DeleteDefault(ctx); err != nil {
		log.Fatal(err)
	}
	env, err := client.Environments.Destroy(ctx, os.Getenv("ENV_ID")) // deletes the data disk and backups
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("%s destroyed\n", env.ID)
}

Next steps

  • SDKs: configuration, errors, pagination and webhook verification in each language.
  • Environments and data and upgrades: the idle ladder, scheduled wake-ups, agent packages and gradual rollouts.
  • Browser: browser VMs with saved sign-in state, and handing control to the end user with a takeover.
  • The API reference.