Skip to content

About

Tenki Cloud Python SDK — cloud sandboxes (microVMs) for AI agents and code execution

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

93 Commits

Folders and files

Repository files navigation

Tenki Python SDK

Python SDK for Tenki: cloud sandboxes (microVMs) for AI agents and code execution.

pip install tenki

The SDK is published as tenki. Existing code that does import tenki_sandbox keeps working (that module ships inside tenki), but new code should use import tenki.

Protobuf compatibility

The SDK supports protobuf>=5.29.5 with no upper bound. Its Python bindings are temporarily generated with protobuf 5.29.5 so applications that require protobuf<6 can install the SDK alongside dependencies such as Memori.

Protobuf 6.31.x emits a benign warning when it loads 5.29.5-generated bindings. If a test suite pins that runtime and promotes warnings to errors, scope the exception narrowly instead of disabling protobuf's runtime checks:

[tool.pytest.ini_options]
filterwarnings = [
  "ignore:Protobuf gencode version 5\\.29\\.5 is exactly one major version older than the runtime version 6\\.31\\..*:UserWarning:google\\.protobuf\\.runtime_version",
]

For local SDK tests, run ./scripts/test.sh; it installs test dependencies in a versioned user cache when needed.

from tenki import Sandbox

# create() waits by default via a single server-held request and returns an
# exec-ready sandbox with data-plane access primed (wait=False to skip).
with Sandbox.create(cpu_cores=2, memory_mb=4096) as sb:
    result = sb.exec("python3", "-c", "print('hello')")
    result.check()
    print(result.stdout_text)

    # fs paths are relative to the sandbox workdir (absolute paths must stay inside it)
    sb.fs.write_text("input.txt", "data")
    print(sb.fs.read_text("input.txt"))

    preview = sb.expose_port(3000, ttl=3600)
    print(preview.url)

The API key determines the Workspace automatically; ordinary Sandbox calls do not require a Workspace ID.

Streaming without retaining output

Pass stream_only=True to sandbox.start(...) to receive stdout and stderr chunks without retaining their history. Consume both proc.stdout and proc.stderr while the command runs. Streaming-only mode bounds each unread output queue and pauses the producer when that queue is full until its reader frees capacity. Waiting without consuming stdout and stderr can block completion. Cancel an unused output stream with proc.stdout.cancel() or proc.stderr.cancel(); the async API uses await for these calls. A wait(timeout=...) timeout in streaming-only mode cancels output delivery to unblock the pump while SIGKILL terminates the command, and a later wait() can return its exit metadata. proc.wait() returns exit status, timing, and disk metadata, with empty stdout and stderr. The default remains output collection, including sandbox.exec(...). The asyncio API accepts the same option and requires consuming both streams with async for.

Releasing a sandbox handle

Call sandbox.release() or await sandbox.release() for the asyncio API after finishing active commands. Release stops credential renewal and frees this handle's local data-plane connection while the sandbox keeps running. It is idempotent and prevents context-manager exit from terminating the sandbox. The released handle cannot start new data-plane operations; use client.get(sandbox.id) or its async equivalent to obtain another handle. An explicit close() still terminates the sandbox after release. Release does not close the shared SDK client. detach() continues to skip context-manager termination while keeping the handle usable; use release() when you also want to free its data plane.

Tailnet attachment

Use TailnetAuthKey so str() and repr() redact the credential. wait_for_online keeps the sandbox in creation until Tenki verifies the registered node.

import os

from tenki import Sandbox, TailnetAttachment, TailnetAuthKey

sb = Sandbox.create(
    tailnet=TailnetAttachment(
        auth_key=TailnetAuthKey(os.environ["TAILNET_AUTH_KEY"]),
        tags=("tag:sandbox",),
        expose_ports=(3000,),
        accept_routes=True,
        wait_for_online=True,
    )
)
print(sb.tailnet_status.state, sb.tailnet_status.fqdn)

The direct key is write-only and never appears in SandboxInfo. The server may reject options from later milestones, including wake-on-connect and recreate-on-resume.

Durable pause

pause_async() returns after the service durably accepts the request and moves the sandbox to PAUSING on a node that supports asynchronous pause. Snapshot capture and persistence continue in the background; acceptance does not mean the VM has stopped or the snapshot is durable. pause() retains its existing completion behavior for compatibility. Call wait_paused() before resuming or relying on a durable checkpoint. It returns at PAUSED and raises PauseFailedError if a verified rollback restores RUNNING. Pass timeout=0 to wait without a deadline. For explicit local cancellation, pass a threading.Event as cancel_event; cancellation raises PauseWaitCancelledError and leaves the remote pause running.

sb.pause_async()
sb.wait_paused(timeout=0)

AsyncSandbox exposes the same operation through await sb.pause_async() and await sb.wait_paused(timeout=0). Its wait accepts an asyncio.Event or threading.Event, and normal task cancellation also stops local observation without cancelling the remote pause. DURABLE_CEPH snapshots can resume on a capability-matched host in the same datacenter while R2 replication continues. DURABLE snapshots are portable to eligible hosts. LOCAL_READY is not resumable for pause operations.

allow_inbound and allow_outbound both default to True on create(), so a new sandbox reaches the internet and can expose ports without passing either argument. Both are create-time settings and cannot be changed on an existing sandbox; sandbox.info.inbound_enabled / sandbox.info.outbound_enabled report what a sandbox was created with.

Restrict outbound access with allow_domains and allow_cidrs. A sandbox that may only reach PyPI:

sb = Sandbox.create(
    allow_domains=["pypi.org", "*.pypi.org", "files.pythonhosted.org"],
)

allow_outbound=False still blocks everything, regardless of allow_domains/allow_cidrs. The allowlist is checked against the names the sandbox resolves, the TLS server name (SNI), and the plaintext HTTP Host. Connections can only reach addresses that an allowed name resolved to or that fall in an allowed CIDR; traffic to an allowed CIDR is not inspected unless the sandbox uses outbound secret injection. UDP port 443 (HTTP/3, QUIC) is blocked for sandboxes with a domain allowlist, so clients fall back to HTTPS over TCP. Unless the sandbox uses outbound secret injection, HTTPS is not decrypted, so another site on the same CDN as an allowed name can be reached by sending its name in the encrypted Host header. Without HTTPS interception, other ports remain open to addresses an allowed name resolves to. Read the allowlist back from sb.info.egress.

Inspecting HTTPS requests

With intercept_https=True, the egress proxy terminates the session's HTTPS with a managed CA the guest trusts, and a request whose Host differs from the TLS server name gets 421. It requires allow_domains. The http_rules then decide which requests pass: each HTTP request must match one rule's domains, methods and path, or it gets 403 egress_http_rule_denied. Domains use the allowlist grammar plus *, empty methods match any method, and the path is exact, a prefix, or an RE2 regex that must match the whole escaped path (the query is not matched). Without rules, every request to an allowed domain passes.

Read-only access to package registries, plus git clone (but not git push) from GitHub:

from tenki_sandbox import EgressHttpRule, Sandbox

registries = ("github.com", "codeload.github.com", "pypi.org", "files.pythonhosted.org")
sb = Sandbox.create(
    allow_domains=list(registries),
    intercept_https=True,
    http_rules=[
        EgressHttpRule(domains=registries, methods=("GET", "HEAD")),
        EgressHttpRule(
            domains=("github.com",),
            methods=("POST",),
            path_regex=r"^/[^/]+/[^/]+(\.git)?/git-upload-pack$",
        ),
    ],
)

With interception enabled, public traffic is limited to TCP 80/443 through the proxy. Other public ports and protocols are blocked by default. Interception speaks HTTP/1.1 only, and WebSocket upgrades get 501. Git smart HTTP, pip, npm, cargo, Go modules and Maven all work over HTTP/1.1. The CA is installed at /usr/local/share/ca-certificates/tenki-egress.crt and added to the system bundle; unless you set them, NODE_EXTRA_CA_CERTS points at the CA and SSL_CERT_FILE and REQUESTS_CA_BUNDLE at the system bundle. The JVM reads none of those: for Maven or Gradle, import the CA into the JVM truststore (keytool -importcert -cacerts -noprompt -alias tenki-egress -file /usr/local/share/ca-certificates/tenki-egress.crt) or pass a truststore that has it through JAVA_TOOL_OPTIONS.

Additional public ports

Use allowed_ports to allow extra TCP ports with HTTPS interception enabled. The policy accepts up to 64 ports from 1 to 65535. Destination and workspace restrictions still apply; ports 80/443 always use the proxy, and DNS stays on the controlled resolver. Additional ports bypass HTTP inspection, so allowing port 22 permits Git over SSH, including pushes. Tailnet traffic and internal services keep their existing access rules.

session = client.create(
    allow_domains=["github.com"],
    intercept_https=True,
    allowed_ports=[22],
)

Named egress policies

Save a policy once per workspace, from the SDK or with tenki sandbox egress-policies create:

from tenki_sandbox import Client, EgressHttpRule

policy = Client().egress_policies.create(
    "registries",
    allow_domains=["github.com", "pypi.org", "files.pythonhosted.org"],
    intercept_https=True,
    http_rules=[EgressHttpRule(domains=("*",), methods=("GET", "HEAD"))],
)

egress_policies.get, list, update and delete take a name or ID; update and delete need the current expected_revision and fail when another change landed first. AsyncClient().egress_policies has the same methods. Then pin policies by name:

sb = Sandbox.create(
    egress_policies=["registries", "github-read"],
    allow_domains=["api.example.com"],
)

The server merges the named policies with the inline options: domains, CIDRs and additional ports are combined, HTTPS interception is on if any of them asks for it, and the HTTP rules are concatenated. The merged policy must pass the same checks and limits as an inline one. An unknown name fails the create with NotFound naming every missing policy. The session keeps the merged copy, so editing or deleting a named policy later does not change it. sb.info.egress.policy_names reads the pinned names back, and the other fields of sb.info.egress hold the merged policy. AsyncSandbox.create takes the same egress_policies argument.

What survives a pause

Pause captures the VM's full memory, not only its disk: a resumed sandbox is the same running machine, not a reboot. Processes keep their PIDs and their in-memory state, and the guest clock stops for the duration of the pause.

/tmp is cleared across a pause. Keep durable state under /home/tenki, which is preserved.

While a sandbox is paused it serves no traffic: preview requests return 404 until it resumes. The client connection itself is not dropped — a held keep-alive connection returns 200 again after resume — so treat a paused sandbox as unavailable rather than disconnected.

SSH is different: a held transport fails on use while paused and does not recover after resume, so reconnect rather than retry on it. A fresh connection succeeds as soon as the sandbox is RUNNING again.

sb.exec("bash", "-lc", "echo hello > ~/marker")
sb.exec("bash", "-lc", "setsid nohup sleep 3600 >/dev/null 2>&1 </dev/null &")
pid = sb.exec("bash", "-lc", "pgrep -n sleep").stdout.decode().strip()

sb.pause()
sb.resume()

assert sb.exec("bash", "-lc", "cat ~/marker").stdout == b"hello\n"
assert sb.exec("bash", "-lc", f"kill -0 {pid} && echo alive").stdout == b"alive\n"

examples/pause_preserves_memory.py extends this to prove the memory claim directly: it hashes a multi-megabyte buffer that only ever exists in RAM and re-reads it from the same process after resume.

Async (asyncio)

AsyncClient / AsyncSandbox expose the same surface with native async/await, built on grpc.aio — no asyncio.to_thread wrapping required. Use it inside any asyncio server (FastAPI, aiohttp, etc.).

import asyncio
from tenki import AsyncSandbox


async def main():
    async with await AsyncSandbox.create(cpu_cores=2) as sb:
        result = await sb.exec("python3", "-c", "print('hello')")
        result.check()
        print(result.stdout_text)

        await sb.fs.write_text("input.txt", "data")
        print(await sb.fs.read_text("input.txt"))

        proc = await sb.start("bash", "-lc", "read name; echo hi $name")
        await proc.write_stdin("tenki\n")
        await proc.close_stdin()
        async for chunk in proc.stdout:
            print(chunk.decode(), end="")
        (await proc.wait()).check()


asyncio.run(main())

The sync Client / Sandbox remain available for non-async consumers. SSH, dial, and host-port tunnels are also async: AsyncSandbox.ssh() returns an AsyncSSHConn (raw async read/write/close, matching the TS/Go SDKs; needs the tenki[async] extra for websockets), dial() returns an AsyncDialConn, and expose_host_port() / expose_host_port_resilient() return async tunnels with await tunnel.terminated.wait() and on_terminated callbacks. For sync and async resilient tunnels, address, port = tunnel.endpoint() reads one established connection. It retains the last endpoint during reconnect and after close.

Auth

Auth resolution:

  1. auth_token= passed to Client or Sandbox.create
  2. api_key=, an alias for auth_token
  3. TENKI_AUTH_TOKEN
  4. TENKI_API_KEY

Credentials must be a Tenki API key or service token starting with tk_. Missing credentials raise MissingAuthTokenError; nonempty credentials with any other prefix raise InvalidAuthTokenError. Blank or whitespace-only values count as absent. Requests authenticate with an Authorization: Bearer header.

Migrating to v0.7.0

Version 0.7.0 removes the generated workspace settings and pause-retention RPCs. Use get_usage() for read-only workspace usage and limit data. Its shared concurrency entry now uses the max_concurrent_jobs key.

Migrating to v0.6.0

Version 0.6.0 removes the cookie_name client and sandbox option and support for Ory session tokens and browser cookie values. Pass a tk_ API key or service token through auth_token; the SDK sends it as an Authorization: Bearer credential.

TENKI_API_ENDPOINT overrides the API URL; legacy TENKI_API_URL is also accepted.

Process API

exec collects stdout/stderr and returns a result:

result = sb.exec("npm", "test", cwd="app", timeout=60, env={"CI": "1"})
print(result.stdout_text)
result.check()

Argv may be passed as separate arguments or as a single list:

sb.exec("sh", "-c", "echo hi")
sb.exec(["sh", "-c", "echo hi"])  # equivalent

start returns a live process:

proc = sb.start("bash", "-lc", "read name; echo hello $name")
proc.write_stdin("tenki\n")
proc.close_stdin()
for chunk in proc.stdout:
    print(chunk.decode(), end="")
proc.wait().check()

Signals are enqueued, not awaited: neither call waits for the process to die — matching subprocess.Popen.kill() — and wait() is what blocks until it is actually dead:

proc = sb.start("sleep", "3600")
proc.signal("SIGTERM")  # returns once the frame is queued
result = proc.wait()  # returns once the process has exited
print(result.signal)  # "terminated"

The two differ before the process has started. signal() waits for the guest to acknowledge the spawn first, so on a slow start it blocks for up to 30s and raises TimeoutError if the acknowledgement never arrives. kill() skips that wait and always returns immediately, which makes it the safer call during startup.

result.signal carries the guest's own name for the signal, not the name you passed: SIGTERM reports "terminated" and SIGKILL reports "killed". Compare against those values, not against "TERM"/"KILL".

kill() takes no argument and always sends SIGKILL; use signal(name) for anything else. signal() accepts KILL, TERM, INT, HUP, USR1 and USR2 (with or without the SIG prefix) and raises ValueError for anything else — note the Go and TypeScript SDKs silently downgrade an unknown signal to SIGTERM instead. One name escapes the check: signal("unspecified") is accepted and sends the protobuf zero value, which the guest maps to no signal at all, so it is a silent no-op rather than a ValueError. Do not port that no-op to the other SDKs — Go and TypeScript map unspecified to SIGTERM like any other unrecognized name, so there it terminates the process.

After a normal exit both calls are no-ops rather than errors. The one exception: if the handle already failed with a stream error, signal() re-raises that error, while kill() stays silent unconditionally.

wait(timeout=...) sends SIGKILL and raises TimeoutError when the timeout expires. Once the process is running the handle stays usable, so you can call wait() again for the exit result. Do not rely on that during startup: if the timeout expires while the run stream is still being established, the SIGKILL it sends also cancels the establishment retry, and every later wait() raises the underlying stream error instead of returning a result.

The asyncio client mirrors this exactly: await proc.signal(...) and await proc.kill() resolve at enqueue, await proc.wait() at exit.

Process lifetime is bound to the Run stream, not to the handle. Once that stream tears down — the connection breaks, or the edge times the idle connection out (~30s) — the platform sends SIGTERM, escalates to SIGKILL after 5s and reaps within 10s.

Dropping the handle does not close the stream on its own: the pump thread keeps reading until the process exits or the stream fails. An abandoned process can keep running, and one that keeps writing output keeps its own stream alive indefinitely. To stop a process, signal it and wait() rather than abandoning it. There is no reattach.

Use shell() when you want shell parsing:

sb.shell("python3 -m http.server 3000 >/home/tenki/server.log 2>&1 &")

Timeouts

Commands are unbounded by default — omit timeout or pass 0. A positive timeout is sent to the guest-agent, which enforces it: on expiry it signals the process (SIGTERM, escalating to SIGKILL) and reports the run as timed out.

That is not an exception — it comes back as an ordinary CommandResult with timed_out set and ok false, carrying the output captured before the budget expired:

result = sb.exec("npm", "ci", timeout=120)
if result.timed_out:
    # result.reason is "timeout", or "grace_timeout" when the guest could not
    # reap the process. Partial output usually shows where it stalled.
    print(result.reason, result.stdout_text)

Passing check=True raises CommandFailedError for a timed-out run, since ok is false.

The guest caps the request at its own configured maximum command timeout, and older guest-agents accept timeout but ignore it, leaving the run unbounded.

Process.wait(timeout=...) is a separate, local bound: it limits how long your call blocks and raises the builtin TimeoutError, saying nothing about the command's own fate.

Background processes and long-running services

Note the redirect in the shell() example above — it is required, not stylistic. A command returns when its stdout and stderr reach EOF, not when the shell exits. A backgrounded process inherits both streams and holds them open, so this waits for the server rather than the shell:

sb.shell("python3 -m http.server 3000 &")  # hangs

Redirecting only stdout is not enough — stderr still holds the stream open — and nohup does not help, because it blocks SIGHUP rather than stream inheritance. This is standard POSIX behavior, the same as subprocess.run(..., capture_output=True).

To keep hold of a service instead, use sb.start(...) and read from the returned process; for services you always want running, start them from a template start command.

That last option is the durable one. Every exec child runs inside the guest-agent's own systemd cgroup, so a guest-agent restart kills it, and nohup does not change that. Pause and resume restore VM memory, so a backgrounded process does survive a pause with the same PID — which makes an ad-hoc service look more durable than it is. Anything load-bearing belongs in a template start command with a readiness probe.

Process cwd values follow the guest contract: relative paths are normalized under the sandbox guest workdir (/home/tenki by default), absolute paths are used unchanged, and missing or non-directory targets fail before the process starts.

cwd sets the spawned process's directory. A login shell (bash -lc) sources the guest's startup files first, so a cd in ~/.bashrc or ~/.profile runs before your command and wins over cwd; use bash -c when the directory must hold.

Sandbox lifetime

Long-lived sandboxes are a parameter choice at create(), not a separate API:

sb = client.create(
    sticky=True,              # long-lived session: no automatic lifetime limit
    pause_retention=24 * 3600,# how long a paused session is kept resumable
)
  • max_duration caps total lifetime; sb.extend(seconds) pushes the deadline (sb.info.timeout_at) while running.
  • sticky=True runs the session until it is terminated, for keep-warm use cases. It discards max_duration. Manual pause and resume do not clear sticky.
  • idle_timeout_minutes is deprecated and ignored: sandboxes do not auto-pause on idle. Use max_duration, or sb.pause() to pause explicitly.
  • client.list(sticky=True) filters for long-lived sessions in the API key's Workspace.

When the service discards a create option, sb.warnings retains the structured warning. By default, Client and AsyncClient emit a SandboxWarningNotification. Pass warning_handler to route warnings through your logger, or pass None to suppress emission while still reading sb.warnings:

import logging

from tenki import Client, SandboxWarningCode

logger = logging.getLogger(__name__)
client = Client(warning_handler=lambda warning: logger.warning(warning.message))
sb = client.create(sticky=True, max_duration=8 * 3600)
assert sb.warnings[0].code == SandboxWarningCode.STICKY_OVERRIDES_MAX_DURATION

SSH

For tools that speak SSH (paramiko, scp, IDE remote dev), the SDK can mint a short-lived OpenSSH user certificate for your session and open a transport to the sandbox SSH gateway. No keys are provisioned into the guest; the engine signs your local public key and the gateway verifies the certificate.

Requires pip install websocket-client paramiko (websocket-client for the transport, paramiko if you want a client in-process).

import subprocess
import paramiko

# 1. local keypair (any OpenSSH key works; ed25519 shown)
subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", "id_tenki"], check=True)

# 2. engine signs a short-lived user cert for this sandbox
cert = sb.issue_ssh_cert(open("id_tenki.pub").read(), ttl=600)
open("id_tenki-cert.pub", "w").write(cert.ssh_cert)

# 3. open the gateway transport and run a paramiko session over it
pkey = paramiko.Ed25519Key.from_private_key_file("id_tenki")
pkey.load_certificate("id_tenki-cert.pub")

transport = paramiko.Transport(sb.ssh())  # WebSocket-backed socket
transport.connect(username="tenki", pkey=pkey)
session = transport.open_session()
session.exec_command("echo hello-over-ssh")
print(session.makefile().read().decode())
transport.close()

Notes:

  • sb.ssh() / client.ssh(session_id) discover an active gateway and return SSHConn, an io.RawIOBase socket usable anywhere paramiko accepts one.
  • The SSH username is tenki.
  • TENKI_SANDBOX_GATEWAY_URL overrides the gateway WebSocket URL (it is otherwise derived from the API endpoint).
  • Certificate RPCs use the Connect protocol over HTTPS (same as the Go SDK and the tenki CLI), so they work through standard HTTP load balancers.
  • sb.update_ssh_authorized_keys([...]) additionally plants long-lived public keys in the guest's authorized_keys if you prefer key-based auth for the in-guest sshd.

Disk space

The root disk defaults to 5 GiB and holds the base image as well as your work, so a fresh sandbox already reports around 56% used. It is fixed at create time and cannot be grown in place — pass disk_size_gb (5–100) to create if you need more. Budget for it before a large install.

Every CommandResult carries disk, and check=True folds a full disk into the raised CommandFailedError. result.disk.exhausted reports whether the sandbox ran out of space at any point during the command — check it even when the command exited 0, since npm reports ENOSPC as a warning and still exits successfully with a broken install.

Resource APIs

from tenki import Client, GiB

client = Client()

volume = client.volumes.create(
    name="cache",
    size_bytes=10 * GiB,
)

preview = client.preview_urls.create(
    slug="demo",
    session_id=sb.id,
    port=3000,
)

Registry

Delete an untagged, non-latest, unshared registry version by image and snapshot ID:

result = client.registry.delete_version(
    "11111111-1111-1111-1111-111111111111",
    "55555555-5555-5555-5555-555555555555",
)

Templates (typed builder)

TemplateSpec is an immutable typed recipe (every builder call returns a new value). Builds freeze the normalized spec + spec_hash server-side and return a private digest-addressed Image, the normal sandbox launch source.

from tenki import Client, TemplateSpec

client = Client()

spec = (
    TemplateSpec()                     # defaults to base image "sandbox"
    .from_image("sandbox-v2")          # or .from_template(...) / .from_snapshot(...)
    .with_git_context("https://github.com/acme/node-api", "main")
    .run("npm ci", name="Install dependencies")
    .runtime_env({"NODE_ENV": "production"})
    .start(["npm", "start"], ready_when=[{"http": "http://localhost:3000/health"}])
)
spec.validate()  # aggregate local validation; server stays authoritative

template = client.templates.create(name="node-api", spec=spec)

build = client.templates.build(
    template,                          # resource objects in, IDs as fallback
    build_secrets={"GITHUB_TOKEN": token},  # explicit request-time values
    wait_for_completion=True,
    on_event=lambda e: print(e),       # one ordered log/progress handler
)

sb = client.create(image=build.image, wait_for_runtime=True)

wait_build(build_id) reconnects to an existing build (ordered, deduplicated events). Local interruption (Ctrl-C or cancel_event) stops observation only; client.templates.cancel_build(build) cancels remotely. Waited failures raise TemplateBuildFailedError carrying the final redacted build; runtime waits raise TemplateRuntimeFailedError without terminating the running sandbox. Strict authored JSON import/export: spec.to_json() / TemplateSpec.from_json(text). Checkout mode uses "contents" or "directory"; runtime runAt, restartPolicy, and snapshotMode use short values such as "build", "on-failure", and "memory". Full protobuf enum names remain accepted; unknown fields and enum values are rejected. See examples/template_builder.py for the full filesystem/memory workflow.

Workspace secrets

Workspace clients manage secrets independently of sandbox Sessions:

from tenki import WorkspaceClient

with WorkspaceClient(workspace_id=workspace_id) as workspace:
    secret = workspace.secrets.create(
        "TOKEN", value_bytes,
        request_id=request_id,
    )

AsyncWorkspaceClient has the same methods and arguments. Use async with and await each operation. Authentication uses TENKI_AUTH_TOKEN or TENKI_API_KEY. Set base_url or TENKI_CLOUD_API_URL to override the cloud API endpoint independently of sandbox configuration. Set workspace_id once on the client. An omitted or empty workspace ID uses the authenticated workspace key's scope; other callers must specify one.

create, update, get, list, list_versions, revoke, and delete return metadata only. Values are bytes; omitted or None on update retains the value, while b"" creates an empty value. Update, revoke, and delete require expected_revision. To select an existing version, update active_version with value omitted. Revoking without version revokes every version. Revoking the active version revokes the secret until a new value or an unrevoked version is made active.

Mutations generate a request ID when none is supplied. For retries after an uncertain result, supply and reuse the same request ID and identical arguments. The SDK does not retry mutations automatically. WorkspaceSecretError.code preserves the RPC status, including revision conflicts.

Managed runtimes reference workspace secret names, without accepting their values:

runtime = TemplateSpec().start("npm start", secret_env={"API_TOKEN": "app-token"})
sandbox = client.create(
    image="team/base:v1",
    direct_runtime=runtime,
    secret_overrides={"app-token": "development-token"},
)

start with an argument list and process_compose also support secret_env in stored templates. Async clients accept the same options. Names resolve in the launching workspace. direct_runtime accepts a runtime-only spec and starts at boot; use create options for image and resources. Secret targets cannot also appear in ordinary runtime/session env. Session info and snapshot metadata expose the read-only has_runtime_secrets marker.

Runtime secret files

Put secrets://API_TOKEN in any text file, regardless of filename or extension. Built templates capture unresolved source text from the guest image; direct launches supply content read on the caller. Values resolve from the launching workspace before managed startup.

from pathlib import Path
from tenki import TemplateSpec

# Built template: source is already inside the image.
spec = TemplateSpec().start("python3 /app/server.py", secret_files=[
    {"source": "/app/env.tpl-sandbox", "path": "/app/.env"},
])

# Direct launch: read on the caller and upload unresolved text.
session = client.create(
    image="my-template:latest",
    secret_files=[{"path": "/app/.env", "content": Path("env.tpl-sandbox").read_text(encoding="utf-8")}],
    secret_overrides={"API_TOKEN": "STAGING_API_TOKEN"},
)

The same options are available on async clients and sandbox helpers.

Rendering performs one literal pass, without YAML/JSON/dotenv escaping, environment expansion, or recursive substitution. A backslash before a marker escapes it. Authors must ensure the actual consumer accepts the result; do not shell-source rendered secret text. Use a raw reference for exact-byte credentials, including binary values.

Allowed destinations are under /home/tenki/, /workspace/, or /app/. Files are private, owned by tenki, and replaced atomically. Duplicate destinations, unsafe paths, and missing references fail startup. Limits: 64 KiB per secret, 256 KiB per source/output file, 1 MiB total source/output, 32 files, and 64 references across environment and files.

Guest values remain frozen across retries, restart, and ordinary resume; replacement Sessions adopt updates. Escape a file marker as \secrets://NAME to preserve secrets://NAME for separately authorized outbound injection; the marker grants no authority by itself. See the file delivery contract for lifecycle and path details.

Transparent HTTPS secrets

Create API_TOKEN through the workspace Secrets client, then save and select a request policy:

from tenki import SecretRequestRule

workspace_client.secrets.policies.create("example", [SecretRequestRule(
    origin="https://api.example.com", methods=["POST"], path_prefix="/v1/",
    header="Authorization", secrets=["API_TOKEN"],
)])
sandbox = client.create(secret_policies=["example"])

Sync and async clients support policy CRUD and selection. Use secret_policies in template start or process_compose for required policies.

Send Authorization: Bearer secrets://API_TOKEN from the sandbox. The reference uses the actual secret name. No alias or injection override is required. Rules can instead select a queryParameter or jsonPointer, with one location per rule and one or more names in secrets.

Policies belong to a workspace and apply only to sessions that select them. Template-required names resolve in the launching workspace; explicit selections add to them. Policy edits and deletion affect subsequent requests on attached sessions. Rotation uses the active value, and revoked or deleted secrets are denied. Deleting and recreating a name does not retarget existing bindings.

Every referenced location must match a selected policy before the request is forwarded. Existing egress rules still apply. An unavailable resolver fails closed. Ordinary values and absent fields remain unchanged.

HTTPS interception requires a capable node and guest trust in the managed CA. Permit only trusted origins and paths: an upstream that echoes credentials can expose a value to the guest. See the workspace secrets guide for location limits and lifecycle behavior.

About

Tenki Cloud Python SDK — cloud sandboxes (microVMs) for AI agents and code execution

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages