One runtime, five product surfaces: CPU/GPU containers that start in seconds and bill by the second
Containers defined at runtime for untrusted or agent code. gVisor or VM isolation, egress control, snapshots, sidecars.
~8 guide pages · V2 backend default since SDK 1.6
Decorate Python, deploy, call .remote()/.map(). Autoscaling, cron, web endpoints, Servers.
~40 guide pages · the original Modal
Managed LLM inference from the Model Library or your weights. OpenAI/Anthropic-compatible. Shared (per-token) or Dedicated.
New in 2026 · modal endpoint CLI
Fine-tune, RL, eval on GPUs that spin up in seconds. Tinker-compatible API, multi-node clusters.
T4 → B300 · @modal.clustered()
Hosted notebooks on Modal compute, priced like Sandboxes. Idle auto-stop, GPU attach.
Sandbox pricing tier
| You are... | Start with | Then add |
|---|---|---|
| Running untrusted / agent-generated code (our case: AgentVetter scanning MCP servers & skills) | Sandboxes | Named Images, domain allowlists, readiness probes, tags |
| Batch-processing a dataset on GPUs | Functions + .map() | Volumes for weights, @modal.batched |
| Serving an open-weights LLM to your app | Endpoints | Dedicated Endpoint for custom weights; affinity routing |
| Fine-tuning / RL with many rollouts | Training | Sandboxes as RL environments, @modal.clustered |
| Building a low-latency HTTP/WebSocket app | Servers (@app.server) | Sticky sessions, proxy tokens |
| Exploring in a notebook with GPU bursts | Notebooks | Promote cells to Functions |
Our modal-power-user-guide.md (4 Aug 2026) was built on SDK 1.5.1. Three minor releases later, these are the deltas that matter.
| Area | Then (Aug 2026) | Now (Oct 2026, SDK 1.6.1) |
|---|---|---|
| Sandbox backend | V1; Sandbox.create() returned immediately | V2 is default (1.6.0). create() blocks until scheduled; unsatisfiable placement raises ResourceExhaustedError. >20 creates/sec. |
| Sandbox filesystem API | sb.open()/ls()/mkdir()/rm() deprecated | Removed in 1.6.0 — only sb.filesystem.* works (GA 14 Aug). |
| Runtime | gVisor only | runtime="vm" (KVM) added — Docker-in-Sandbox, FUSE, nested cgroups. gVisor still default "but may change". |
| Egress control | cidr_allowlist | outbound_cidr_allowlist + outbound_domain_allowlist (beta); runtime policy swap (alpha); inbound_cidr_allowlist. |
| Secrets in Sandboxes | Env-var injection only | Outbound Policy secret injection (beta) + Sidecars (beta, public alpha 28 Aug). |
| Snapshot retention | FS snapshots kept forever | 30-day default TTL (1.5.0 breaking); ttl=None to keep. |
@app.cls() | Custom __init__ allowed | Removed — use modal.parameter() + @modal.enter(). |
| Observability | Dashboard + modal app logs | Sandbox.logs, App.logs, modal function/server stats, per-Sandbox CPU/memory/pressure & Exit Reasons charts. |
| New primitives | — | @app.server(), @modal.sessioned(), @modal.clustered(), modal endpoint CLI, modal skills CLI. |
modal changelog --since=1.5 CLI is a genuinely good idea for agents.Runtime-defined containers for untrusted code · gVisor or KVM · 5-min default lifetime, up to 24h · the primitive AgentVetter is built on
python, no modal runimport modal
app = modal.App.lookup("agentvetter", create_if_missing=True)
image = modal.Image.from_name("agentvetter-scanners") # pre-built, see Images
sb = modal.Sandbox.create(
app=app, image=image,
timeout=10 * 60, # hard cap (default is only 5 min)
idle_timeout=120, # 2nd safety net for hung scanners
tags={"scan_run_id": "sr_01", "batch_id": "b_01"},
)
p = sb.exec("skill-scanner", "scan", "/work/skill", "--format", "json")
p.wait()
print(p.returncode, p.stdout.read()[:200])
sb.terminate(wait=True) # SIGKILL → sb.returncode == 137
print("sandbox exit:", sb.returncode)
pip install modal (or uv pip install modal), then modal setupApp.lookup(name, create_if_missing=True)exec() commands, read stdout as a stream or .read()terminate() — or you pay until timeoutSandbox.create() now blocks until scheduled. If placement can't be satisfied (region, GPU, resources) it raises modal.exception.ResourceExhaustedError instead of returning a handle that later fails. Wrap creation in the same error handling as exec.
python — good — but doesn't say you need modal setup first or which Python versions work.| Control | Default | Notes |
|---|---|---|
timeout | 300 s | Hard cap; max 24 h. Beyond that: FS snapshot + new Sandbox. |
idle_timeout | none | Active = running exec, stdin writes, or open Tunnel TCP. Catches hung scanners faster than timeout. |
exec(..., timeout=) | none | Per-command cap; respected by wait()/poll() since 1.1.0. |
| Readiness probe | — | Runs ≤5 min; wait_until_ready() raises modal.exception.TimeoutError (not builtin) and does not terminate the Sandbox. |
| Where | Code | Meaning |
|---|---|---|
p.returncode | 0 / n | Scanner's own exit status (e.g. Snyk uses non-zero for "findings present") |
sb.returncode | 137 | SIGKILL — from terminate(), timeout, or idle_timeout |
sb.returncode | OOM | Dashboard "Exit Reasons" distinguishes memory-limit vs memory-manager kills (Aug 2026) |
sb.poll() | None | Still running (fixed in 1.0.1 — used to be 0) |
scan_run_scanners.status can separate it from a scanner crash. Note terminate() no longer waits by default (since 1.0.3) — use terminate(wait=True) before reading returncode.
returncode is after an idle_timeout kill vs a hard timeout kill (both 137? presumably). Verify empirically.sleep()Modal polls a check inside the Sandbox every interval_ms (default 100) and fires the Ready event. Two kinds:
| Probe | Succeeds when | Use for |
|---|---|---|
Probe.with_tcp(port) | Port accepts a connection | MCP server over HTTP/SSE, Jupyter, any server |
Probe.with_exec(cmd…) | Command exits 0 | File exists, deps installed, docker info |
mcp-scanner connects to a live MCP server. Boot the server as the Sandbox entrypoint with a TCP probe, call wait_until_ready(), then exec the scanner. No guessed sleep. For stdio servers, use an exec probe on a "ready" marker file written by a wrapper.
sb = modal.Sandbox.create(
"npx", "-y", "@modelcontextprotocol/server-everything",
"sse", "--port", "3001",
readiness_probe=modal.Probe.with_tcp(3001),
app=app, image=image, timeout=600,
)
try:
sb.wait_until_ready() # ≤ 5 min, then raises
except modal.exception.TimeoutError:
sb.terminate() # probe failure does NOT kill it
mark_unreachable(scan_run_id)
raise
p = sb.exec("mcp-scanner", "config",
"--url", "http://localhost:3001/sse",
"--format", "json", timeout=240)
modal.exception.TimeoutError is not the builtin and that the probe timeout doesn't terminate the Sandbox.interval_ms upper bound unstated
| Level | Parameter | Behaviour | Maturity |
|---|---|---|---|
| Full block | block_network=True | Drops all outbound; cannot combine with any allowlist or ports | GA |
| IP-range allowlist | outbound_cidr_allowlist=[...] | Any protocol to listed CIDRs. (cidr_allowlist is deprecated.) | GA |
| Domain allowlist | outbound_domain_allowlist=[...] | TLS/443 only, matched on SNI; Modal resolves the hostname itself. Non-TLS to non-CIDR IPs is blocked. Denials are logged to the Sandbox system stream / App logs. | Beta |
| Runtime swap | sb._experimental_set_outbound_network_policy(...) | Replace the policy live — e.g. open during npm install, then lock down. Each list type must be set at create time (["*"] / ["0.0.0.0/0"]). | Alpha |
| Inbound | inbound_cidr_allowlist=[...] | Restricts who can reach Tunnels / Connect Tokens | GA |
Host header. The allowlist doesn't stop that; a Sidecar proxy that terminates TLS does. ECH connections match only the outer public name.
outbound_domain_allowlist (e.g. ["api.snyk.io", "*.cisco.com", "api.tessl.io", "github.com"] + the scan target). The "denied-egress logging" fixture gap closes for free: denials land in App logs since 1.5.0.
| Approach | How | Key visible inside Sandbox? | Constraints |
|---|---|---|---|
| Env-var Secret GA | secrets=[modal.Secret.from_name("snyk")] → $SNYK_TOKEN | Yes — any process can read and exfiltrate it | Works with everything; simplest |
| Outbound Policy Beta | modal.experimental.OutboundPolicy().with_header_replacement(domain, secret, headers) → Modal's proxy terminates TLS and injects Authorization outside the Sandbox | No — never in env; CA cert at /etc/modal/sandbox-egress-ca.crt | HTTP/1.1 only (no gRPC) · named Secrets only · cannot combine with outbound_domain_allowlist, block_network, CIDR lists, or memory snapshots · ≤25 header values |
| Sidecar proxy Beta | Run a trusted mitmproxy-style container next to the Sandbox; proxy_traffic_via_sidecar relays all 443 traffic to it; Secret injected only in the Sidecar | No | Pre-built images only · Sidecar's own egress policy governs relayed traffic · cannot combine with outbound_domain_allowlist on the main Sandbox |
policy = (modal.experimental.OutboundPolicy()
.with_header_replacement(
domain="api.snyk.io",
secret=modal.Secret.from_name("snyk"),
headers={"Authorization": "token $SNYK_TOKEN"})
.with_header_replacement(
domain="*.cisco.com",
secret=modal.Secret.from_name("cisco"),
headers={"X-Api-Key": "$CISCO_AI_DEFENSE_API_KEY"}))
sb = modal.Sandbox.create(app=app, image=image,
_experimental_outbound_policy=policy)
SNYK_TOKEN. The Outbound Policy row fixes that, but it is mutually exclusive with the domain allowlist we also want. Options: (a) env Secrets + domain allowlist (today's posture, leak-prone), (b) Outbound Policy, lose the allowlist (every domain open), (c) Sidecar proxy doing both allowlist and injection — most work, only option that gives both. Snyk's CLI may also need the token via env, not header; verify hands-on per scanner.
image = (
modal.Image.debian_slim(python_version="3.12")
.apt_install("git", "curl", "nodejs", "npm") # Node for npx tessl + Node MCP servers
.uv_pip_install("uv==0.9.2")
.run_commands(
"uv tool install cisco-ai-mcp-scanner==4.8.2", # pin! drifted 4.7.1→4.8.2 in 3 days
"uv tool install cisco-ai-skill-scanner==1.3.0",
"uv tool install snyk-agent-scan==0.5.1",
"npm i -g tessl@0.94.0",
)
.env({"PATH": "/root/.local/bin:$PATH"})
)
with modal.enable_output():
image.build(app=app).publish("agentvetter-scanners:2026-10-05")
# scan code — never triggers a build, fails fast with NotFoundError
image = modal.Image.from_name("agentvetter-scanners:2026-10-05")
| Feature | Why it matters |
|---|---|
| Named Images (1.5.0) | publish("name:tag") + from_name() = Modal-native registry. Lookup never builds. |
Image.build(app) (1.2.0) | Eager build; otherwise the build happens lazily inside Sandbox.create() |
uv_pip_install / uv_sync | Up to 50% faster than pip; pin versions — cache may invalidate across SDK bumps |
| Immutable external tags | from_registry("ubuntu:24.04") is cached forever; bump the tag to pull a new upstream |
| 2025.06 Image Builder | Modal client deps isolated from yours → fewer conflicts (the mcp SDK clash we hit) |
Image.from_scratch() | Empty image to mount as a scratch dir via mount_image |
modal_app.py install chain so the pinned-dependency syntax and layer order could be drafted. The sketch left is the starting point: pin every scanner, one uv tool install per tool (isolated venvs), and tag the image with the build date.
uv tool install (CLI tools in isolated venvs) vs uv_pip_install — a common need for scanner-style images · Named-image listing CLI (modal image names) is only in release notes
| Kind | TTL | Status |
|---|---|---|
| Filesystem → Image | 30 d (was ∞; ttl=None to keep) | GA |
| Directory → mountable Image | 30 d | GA (12 Aug) |
| Memory (processes too) | 7 d, not extendable | Alpha |
Fork: one FS snapshot → N identical Sandboxes. Delete via modal.experimental.image_delete(id) — there's no list API, track IDs yourself. Volumes are not in FS snapshots.
| gVisor (default) | VM | |
|---|---|---|
| Isolation | Userspace kernel | KVM, own Linux kernel |
| GPU | ✅ | ❌ |
| Docker / FUSE / cgroups | ❌ | ✅ (512 GiB root fs) |
| Memory snapshots | alpha | enabled customers only |
| Nested virt | — | Team/Enterprise on request |
Docs say the default "may change in the future" — pin runtime="gvisor" explicitly if you depend on it.
Extra containers on the same host, bridged by name (main, web, …). Share the Sandbox's CPU/memory. Up to 250 per Sandbox.
Patterns: agent harness ↔ tool executor split; credential-injecting proxy; Redis/DB next to the app; HTTPS inspection via proxy_traffic_via_sidecar.
Limits: pre-built images only, no stdio on entrypoint, no memory snapshot, VM runtime needs a fixed memory reserve.
| Rate (per second) | Functions | Sandboxes / Notebooks |
|---|---|---|
| CPU, per physical core | $0.0000131 | $0.00003942 (≈3.0×) |
| Memory, per GiB | $0.00000222 | $0.00000667 (≈3.0×) |
| GPU | Same table for both — T4 $0.000164/s … H100 $0.001097/s … B300 $0.001972/s | |
| Minimum | 0.125 cores per container; billed max(request, actual) — Sandboxes burst above request | |
| Plans | Starter $0 + $30/mo credit, 100 containers · Team $250/mo, 5000 containers, custom domains, static-IP proxy, env budgets | |
Cap bursts for adversarial workloads: cpu=(0.5, 4.0), memory=(512, 2048). Size from the Sandboxes tab: p50–75 CPU, p90–95 memory, watch CPU Pressure and Exit Reasons.
Rates from modal.com/pricing on 5 Oct 2026. Ignores egress ($0.04/GiB beyond 1 TiB free) and the $30/mo Starter credit.
max(request, actual) billing are explained with a worked sizing procedure.| Mechanism | What you get |
|---|---|
Sandbox.create(verbose=True) | Every exec and filesystem op appears in the Sandbox log (1.0.4) |
sb.logs.fetch()/tail() (1.5.5) | Entrypoint-process logs by time range. Exec output is yours to capture via p.stdout. |
modal container logs sb-xxx --all --source system | Includes system stream — where egress denials land |
App.logs.stream() (1.5.4) | All logs for the App programmatically |
tags= + Sandbox.list(app_id, tags=) | Find orphans by batch_id; get_tags() round-trips |
sb.get_dashboard_url() (1.3.2) | Deep link to the Sandbox page for your evidence panel |
| Dashboard charts (Aug–Sep 2026) | CPU/Memory/CPU-pressure per Sandbox, Exit Reasons, egress per app, zoomable time ranges |
| Datadog / OTel integrations | Ship container metrics + logs out (Team+) |
# orphan sweep — a scheduled Function
@app.function(schedule=modal.Period(minutes=10))
def reap():
for sb in modal.Sandbox.list(app_id=app.app_id,
tags={"owner": "agentvetter"}):
if sb.poll() is None: # still running
run_id = sb.get_tags().get("scan_run_id")
if supabase_says_finished(run_id):
sb.terminate()
sb.object_id + get_dashboard_url() · the outbound policy you set · container logs --source system · terminate(wait=True) returncode 137. All four are now programmatic; none require screenshots.
Sandbox.logs only covers the entrypoint, prominently
runtime="vm" (no GPU)block_network=True (Cisco config YARA scan)outbound_domain_allowlist (+ CIDR for non-TLS)["*"] + runtime policy swap (alpha)timeout + idle_timeout, terminatename= (named Sandbox)The declarative half of Modal: decorate, deploy, fan out · autoscaler · web endpoints · storage primitives
import modal
app = modal.App("agentvetter-orchestrator",
tags={"team": "agentvetter"})
image = modal.Image.from_name("agentvetter-scanners:2026-10-05")
@app.function(image=image, timeout=900, retries=0,
secrets=[modal.Secret.from_name("supabase")])
def run_scan(scan_run_id: str) -> dict:
... # creates the Sandbox, writes to Supabase
@app.local_entrypoint()
def main(batch_id: str):
ids = fetch_run_ids(batch_id)
for result in run_scan.map(ids): # fan-out, ordered results
print(result["scan_run_id"], result["status"])
| Object | Role |
|---|---|
App | Unit of deployment; owns Functions, Sandboxes, Images. Tags for cost allocation. |
Function / Cls | .remote(), .map(), .spawn() → FunctionCall ID you can persist |
Image | Content-hashed, cached, lazily built; now nameable |
Secret · Volume · Dict · Queue | Managed via .objects.create/list/delete (1.1.2) |
Sandbox | Runtime container; needs an App |
| CLI | Does |
|---|---|
modal run f.py | Ephemeral App; primitive args become flags |
modal serve | Live-reload (uses "recreate" strategy) |
modal deploy | Persistent; --strategy rolling|recreate; rollbacks on Team+ |
modal shell sb-xxx / im-xxx | Drop into a Sandbox or an Image |
modal app/function/server info|stats|logs | Agent-friendly introspection (1.6.0) |
image.add_local_python_source("pkg"), or pass include_source=False when the Image already has them. Use -m for module paths: modal deploy -m agentvetter.app.
modal setup.| Knob | Meaning |
|---|---|
min_containers | Warm floor ( |
max_containers | Ceiling; plan limit 100 / 5000 |
buffer_containers | Headroom kept warm while active |
scaledown_window | Idle linger before scale-in |
@modal.concurrent(max_inputs=N) | N inputs per container (async/threaded) |
@modal.batched(max_batch_size, wait_ms) | Dynamic batching of inputs |
single_use_containers=True | Fresh container per input ( |
nonpreemptible=True | CPU only; 3× price |
startup_timeout | Separate from input timeout (1.1.4) |
Function.update_autoscaler(min_containers=…) changes settings without redeploying — returns the full autoscaler state (1.5.4). Rate limit: ~200 calls/s per workspace with a 5-s burst.
@app.cls(image=image, gpu="L4", min_containers=1,
scaledown_window=300)
@modal.concurrent(max_inputs=8)
class Judge:
model_id: str = modal.parameter(default="qwen3-guard")
@modal.enter() # once per container
def load(self):
self.model = load(self.model_id)
@modal.method()
def score(self, finding: dict) -> float: ...
@modal.exit() # 30-s grace on preemption
def flush(self): ...
@app.cls() classes may not define __init__. Parameters go through modal.parameter(); setup goes in @modal.enter(). The old __enter__ pattern is long gone.
modal run from the CLI (current), or deployed + retries=0 + explicit status writes.
keep_warm/web_endpoint; the docs' deprecation translator is release-notes-only
| Decorator | When |
|---|---|
@modal.fastapi_endpoint() | One function → one route ( |
@modal.asgi_app() / wsgi_app() | Mount a FastAPI/Flask app (must be nullary) |
@modal.web_server(port) | Any server speaking HTTP on a port |
@app.server() 1.5.1 | Ultra-low-latency HTTP primitive; max_concurrency → 503 when full; @modal.sessioned() sticky sessions for WebSockets |
Auth: Proxy Tokens (modal workspace proxy-tokens create) → Authorization: Bearer wk-id.ws-secret. Sep 2026: filter/audit/block unauthenticated endpoints per environment. Request timeout and streaming guides cover SSE.
| Primitive | Use | Gotcha |
|---|---|---|
Volume | Weights, datasets, scratch; $0.09/GiB/mo (1 TiB free) | Not safe for concurrent writes to one file; read_only(), sub_path |
CloudBucketMount | S3/GCS/R2 as a path; OIDC auth | Mount in every container that needs it |
Dict | Network KV, cloudpickle values | Entries expire after 7 days idle; 100 MiB/obj |
Queue | Work queue, partitions | Unlimited on all plans |
asgi_app beyond "ultra-low latency".The 2026 products: managed inference from the Model Library, Tinker-compatible training, multi-node clusters
| Shared | Dedicated | |
|---|---|---|
| Models | Selected Library models | All Library models + custom weights |
| Billing | Per token | Compute (GPU-seconds), scale-to-zero |
| Capacity | Modal-managed | Configurable autoscaling, --compute-region |
| Auth | Proxy Token required | Proxy Token by default; --unauthenticated possible |
| Source | — | Generated serving code is inspectable & forkable |
APIs: OpenAI Chat Completions & Responses, Anthropic Messages, OpenAI Embeddings. CLI: modal endpoint create|info|stats|logs (1.6.1). Integrations guide covers OpenCode, Codex, Claude Code pointed at a Shared Endpoint.
modal workspace proxy-tokens create --name agentvetter-judge
curl "$ENDPOINT_URL/v1/chat/completions" \
-H "Authorization: Bearer $TOKEN_ID.$TOKEN_SECRET" \
-H "Modal-Routing-Affinity-Key: scan-$SCAN_RUN_ID" \
-H "Content-Type: application/json" \
-d '{"model":"<model>","messages":[{"role":"user",
"content":"Is this MCP tool description an injection?"}]}'
Modal-Routing-Affinity-Key per conversation/task keeps KV-cache-friendly requests on the same container. New key after context compaction. Don't use one key per user or app.
behavioral subcommand and skill-scanner --use-llm both need an LLM. A Shared Endpoint with an open-weights model, keyed by scan run, is the cheapest way to make the "LLM-judge" scanner mode reproducible and self-hosted — without GPU plumbing in our App.
| GPU | $/sec | ≈ $/hr |
|---|---|---|
| T4 | 0.000164 | 0.59 |
| L4 | 0.000222 | 0.80 |
| A10 | 0.000306 | 1.10 |
| L40S | 0.000542 | 1.95 |
| A100 40 / 80 GB | 0.000583 / 0.000694 | 2.10 / 2.50 |
| RTX PRO 6000 | 0.000842 | 3.03 |
| H100 SXM5 | 0.001097 | 3.95 |
| H200 SXM | 0.001261 | 4.54 |
| B200 / B300 | 0.001736 / 0.001972 | 6.25 / 7.10 |
Region pinning 1.15–1.75×; non-preemptible 3× (CPU only). GPU Sandboxes are preemptible — design for interruption.
| Capability | Notes |
|---|---|
| Training quickstart | Fine-tune, RL, eval; Sandboxes as RL environments (Modal pitches "1M concurrent" for rollouts) |
| Tinker-compatible API ("Spindle") | Drop-in for Thinking Machines' Tinker client |
| Multi-node training / clusters | @modal.clustered() (1.6.0 GA) + modal.Cluster for rank & private IPs; i6pn private networking; RDMA |
| GPU health & metrics | GPU health page, GPU Metrics, modal.Cluster diagnostics |
| High-performance LLM inference guide | vLLM/SGLang/TRT-LLM engine advice; LLM Engine Advisor tool |
What Modal has published about how the thing works — and how to read their comparison guides
create() now blocks until scheduled — placement became the real cost.
--modal flag to SWE-bench; the 500-task Verified set runs in ~7 minutes. Useful precedent for AgentVetter: a benchmark harness that fans out one Sandbox per task is a solved pattern.
JS/Go SDKs · the Modal agent skill · agent-friendly CLI · RBAC, budgets, OIDC
| SDK | Status | Notes |
|---|---|---|
Python modal 1.6.1 | GA | Pin minor: modal~=1.6.0; breaking changes only in 1.Y.0. Python 3.10–3.14 (3.14t experimental). |
JavaScript modal | Beta | new ModalClient(); full Sandbox API incl. probes, policies, sidecars. Needs Python-SDK ≥1.2 deployed Apps. |
Go modal-client/go | Beta | Same surface; build logs stream automatically. |
| Tool | What |
|---|---|
modal skills install / update | Installs Modal's foundational agent skill (1.5.0) — keep Claude Code / Cursor on current API names |
modal changelog --since=1.5 | Query changelog from the CLI (1.3.5) |
modal app logs --since 4h --search "denied" | Historical, filterable logs; --source system |
modal function|server|app info|stats|logs | Structured introspection; --json with normalised keys |
modal curl | Hit authenticated endpoints without juggling proxy tokens |
pty=True | Sandbox PTY "for better Claude Code support" |
Examples worth cloning: coding agent with LangGraph, Claude Managed Agents on Modal Sandboxes (GitHub), Sandbox pool, safe code execution.
| Capability | Detail | Plan |
|---|---|---|
| Environments | dev/staging/prod split inside a workspace; Restricted Environments with configurable default roles → private envs (Aug 2026) | All |
| RBAC + User groups | Environment roles per member/service user; groups synced via SCIM (Sep 2026) | Team+ |
| Budgets | Workspace and per-Environment monthly caps; usage limits redesigned Aug 2026 | Team+ |
| Billing APIs | Workspace.billing.report()/summary()/rates(), modal billing … — cost by App tag, by resource | Team+ |
| OIDC identity tokens | include_oidc_identity_token=True → $MODAL_IDENTITY_TOKEN; federate to AWS/GCP without static keys; works in Sandboxes & Sidecars | All |
| Service users & tokens | modal token new --expires-in; named proxy tokens; modal token info | All |
| SSO / SCIM / audit logs | Okta, Entra, SAML (XML metadata upload Sep 2026); audit logs | Enterprise |
| Static-IP Proxies | Egress via fixed IPs, region-selectable (Aug 2026); usable from Sandboxes and Sidecars | Team+ |
| Notifications | Slack per app/environment, in-app follows (Sep 2026) | Beta |
Every locked AgentVetter decision that touches Modal, re-checked against the October docs
| AgentVetter decision / assumption | Modal feature today | Verdict | Action |
|---|---|---|---|
| Ephemeral Sandbox per scan, prebuilt image, content copied at runtime | Named Images + Image.from_name(); sb.filesystem.copy_from_local() (2.5× faster since 1.5.3) | Holds | Publish image with a dated tag; migrate any sb.open() usage — removed in 1.6 |
| Static per-scanner + dynamic per-target egress allowlist | outbound_domain_allowlist (beta) with denials logged | Better | Replace any CIDR lists with domains; close the "denied-egress logging" fixture gap using --source system logs |
| Credentials via Modal Secrets (env) — "revisit if insufficient" | Outbound Policy injection; Sidecar proxies | Revisit | Decide env vs policy vs sidecar (slide 2.6). Policy and domain allowlist are mutually exclusive. |
| Fixed sleep before Cisco MCP live scan | Readiness probes (TCP / exec) | Better | Adopt; handle modal.exception.TimeoutError → unreachable |
| Hard timeout → exit 137 = "killed by us" | Unchanged; terminate(wait=True) needed to read it | Holds | Add idle_timeout; verify 137 on idle kill empirically |
| Sandbox tagging for orphan detection | tags= at create, Sandbox.list(tags=) | Holds | Add a 10-min reaper Function (slide 2.9) |
Sandbox default 5 min lifetime; .map() fan-out | Unchanged. V2: create() blocks; ResourceExhaustedError | Adjust | Treat create failures as a 5th status (not_scheduled?) — this is the "5th error status" fixture gap |
| Sandbox cost assumed ≈ Function cost | Sandbox CPU/mem ≈ 3× Functions | Wrong | Budget with the calculator; request 0.25–0.5 cores and let bursting cover peaks |
Node.js in base image for npx tessl + Node MCP servers | No change | Holds | Keep; 2025.06 Image Builder reduces dep conflicts |
| Deeper observability (file/process) deferred | Sidecars + proxy_traffic_via_sidecar; verbose=True | Unlocked | Post-MVP: mitmproxy sidecar for full request capture |
STATUS.md (neomatrix369/agentvetter) ·
2 Modal power-user guide (4 Aug 2026 session) ·
3 Sandboxes ·
4 Networking ·
5 Release notes ·
6 Pricing
idle_timeout beside the hard timeout; domain allowlist instead of CIDR; a reaper cron; the Secrets box is now a choice (env vs Outbound Policy); evidence is fully programmatic.
ResourceExhaustedError becomes the 5th scan_run_scanners status; ④ snapshot TTL for the scanner image (named Images don't expire; FS snapshots do).
Sandbox cheat sheet · rename map · every link in one place
modal.Sandbox.create(
*entrypoint_cmd, app=, name=, tags=,
image=, env=, secrets=, volumes=,
cpu=(req, lim), memory=(req, lim), gpu=,
timeout=300, idle_timeout=None, workdir=,
runtime="gvisor"|"vm",
block_network=, outbound_cidr_allowlist=,
outbound_domain_allowlist=, inbound_cidr_allowlist=,
encrypted_ports=, unencrypted_ports=, h2_ports=,
readiness_probe=modal.Probe.with_tcp(p)|with_exec(..),
proxy=, custom_domain=, pty=, verbose=,
include_oidc_identity_token=, region=, cloud=,
_experimental_outbound_policy=,
_experimental_enable_snapshot=,
experimental_options={"proxy_traffic_via_sidecar": ..})
Sandbox.from_id(id) · Sandbox.from_name(app, name)
Sandbox.list(app_id=, tags=)
p = sb.exec(*cmd, timeout=, pty=, workdir=)
p.stdout / p.stderr / p.stdin · p.wait() · p.returncode
sb.wait_until_ready() · sb.poll() · sb.wait()
sb.filesystem.read_text/write_text/read_bytes/write_bytes
sb.filesystem.copy_from_local/copy_to_local
sb.filesystem.list_files/stat/make_directory/remove/watch
sb.tunnels() · sb.create_connect_token(user_metadata=, port=)
sb.set_tags() · sb.get_tags() · sb.get_dashboard_url()
sb.logs.fetch(since=, until=) · sb.logs.tail()
sb.snapshot_filesystem(ttl=) · sb.snapshot_directory(path, ttl=)
sb.mount_image(path, img) · sb.unmount_image(path)
sb.reload_volumes(timeout=55)
sb._experimental_set_outbound_network_policy(...)
sb._experimental_update_outbound_policy(policy)
sb._experimental_sidecars.create/list/get
sb.terminate(wait=False) · sb.returncode · sb.detach()
modal shell sb-xxx # jump in
modal container logs sb-xxx --all --source system
modal container stop sb-xxx --yes
modal image names # published named images
modal sandbox list # per app
| You'll see | Use instead | Since |
|---|---|---|
modal.Stub | modal.App | 1.0.0 (now AttributeError) |
keep_warm=, concurrency_limit= | min_containers=, max_containers= | removed 1.4.0 |
@modal.web_endpoint | @modal.fastapi_endpoint | 1.0 |
max_inputs=1 on function | single_use_containers=True | 1.3.0 |
cidr_allowlist= | outbound_cidr_allowlist= | 1.4.3 (deprecated) |
sb.open()/ls()/mkdir()/rm()/watch(), FileIO | sb.filesystem.* | removed 1.6.0 |
__init__ in @app.cls; __enter__ | modal.parameter() + @modal.enter() | 1.6.0 |
modal.Mount, mounts= | image.add_local_dir() / add_local_python_source() | 1.0.0 |
Volume.delete(), create_deployed() | Volume.objects.delete()/create() | 1.5.0 |
modal.billing.workspace_billing_report() | Workspace.billing.report() | 1.5.1 |
modal bootstrap | modal endpoint create | 1.6.0 |
Environment.members | Environment.roles | 1.5.3 |
| Snapshots kept forever | ttl=None to opt out of 30-day expiry | 1.5.0 |
Function.from_name(app, "Cls.method") | Cls.from_name(app, "Cls") | 1.4.0 |
modal deploy project.app | modal deploy -m project.app | 1.4.0 |
Our own August guide already had the first three. Everything below that line is new since.