neomatrix369 github.io

Modal

Developer Learning Hub  ·  Serverless compute for AI: Sandboxes, Functions, Endpoints, Training

5
Products
90+
Guide pages
1.6.1
Python SDK
3
SDKs (Py · JS · Go)

Enriched with AgentVetter decisions from Aug–Sep 2026 sessions · verified against modal.com on 5 Oct 2026

→ sections  ·  ↓ go deeper  ·  ★ Reviews toggles docs-quality notes on every slide

Section 01

The Modal Platform

One runtime, five product surfaces: CPU/GPU containers that start in seconds and bill by the second

Five Products, One Container Runtime

Product 1

Sandboxes

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

Product 2

Functions & Batch

Decorate Python, deploy, call .remote()/.map(). Autoscaling, cron, web endpoints, Servers.

~40 guide pages · the original Modal

Product 3

Endpoints

Managed LLM inference from the Model Library or your weights. OpenAI/Anthropic-compatible. Shared (per-token) or Dedicated.

New in 2026 · modal endpoint CLI

Product 4

Training

Fine-tune, RL, eval on GPUs that spin up in seconds. Tinker-compatible API, multi-node clusters.

T4 → B300 · @modal.clustered()

Product 5

Notebooks

Hosted notebooks on Modal compute, priced like Sandboxes. Idle auto-stop, GPU attach.

Sandbox pricing tier

Your code / SDK Python · JS · Go · CLI Control plane Apps · Images · Secrets · Volumes Sandboxes Functions / Servers Endpoints / Training Multi-cloud pool gVisor / KVM · CPU + GPU AWS · GCP · OCI · more
Docs Review — Guide landing + product pages
ClarityBThe Guide TOC is excellent once you're in it, but the product split (Sandboxes vs Endpoints vs Training) lives on marketing pages, not the docs. Newcomers meet "Functions" first and only discover Sandboxes as a sidebar item.
PrerequisitesBPython ≥3.10 is implied (3.9 was dropped in SDK 1.3) but not stated on the landing page.
ExamplesAThe Examples index is one of the best in the industry — every example is runnable and tagged.
LevelAAccessible; jargon is defined inline.
⚠️ Could improve
A single "which Modal product am I looking for?" page mapping use-case → product → first doc would cut the sidebar hunt.

Who Uses What?

You are...Start withThen add
Running untrusted / agent-generated code (our case: AgentVetter scanning MCP servers & skills)SandboxesNamed Images, domain allowlists, readiness probes, tags
Batch-processing a dataset on GPUsFunctions + .map()Volumes for weights, @modal.batched
Serving an open-weights LLM to your appEndpointsDedicated Endpoint for custom weights; affinity routing
Fine-tuning / RL with many rolloutsTrainingSandboxes as RL environments, @modal.clustered
Building a low-latency HTTP/WebSocket appServers (@app.server)Sticky sessions, proxy tokens
Exploring in a notebook with GPU burstsNotebooksPromote cells to Functions
Mental model: Functions are declared at import time and deployed; Sandboxes are created at runtime by code that is already running (your CLI, a Function, your backend). Everything else — Images, Secrets, Volumes — is shared between the two.
Docs Review — Persona mapping
ClarityBInferred from product pages and the Guide; Modal doesn't ship a persona table.
PrerequisitesCNo "Functions vs Sandboxes — which one?" page exists. The Sandboxes intro has a bullet list of use-cases, which is the closest thing.
⚠️ Could improve
Add a one-screen comparison of Function vs Sandbox vs Server with the "created at runtime vs declared at deploy" distinction up front.

What Changed Since Our August Guide

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.

AreaThen (Aug 2026)Now (Oct 2026, SDK 1.6.1)
Sandbox backendV1; Sandbox.create() returned immediatelyV2 is default (1.6.0). create() blocks until scheduled; unsatisfiable placement raises ResourceExhaustedError. >20 creates/sec.
Sandbox filesystem APIsb.open()/ls()/mkdir()/rm() deprecatedRemoved in 1.6.0 — only sb.filesystem.* works (GA 14 Aug).
RuntimegVisor onlyruntime="vm" (KVM) added — Docker-in-Sandbox, FUSE, nested cgroups. gVisor still default "but may change".
Egress controlcidr_allowlistoutbound_cidr_allowlist + outbound_domain_allowlist (beta); runtime policy swap (alpha); inbound_cidr_allowlist.
Secrets in SandboxesEnv-var injection onlyOutbound Policy secret injection (beta) + Sidecars (beta, public alpha 28 Aug).
Snapshot retentionFS snapshots kept forever30-day default TTL (1.5.0 breaking); ttl=None to keep.
@app.cls()Custom __init__ allowedRemoved — use modal.parameter() + @modal.enter().
ObservabilityDashboard + modal app logsSandbox.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.
Docs Review — Release notes & changelog
ClarityARelease notes are detailed, dated, and name breaking changes explicitly. The modal changelog --since=1.5 CLI is a genuinely good idea for agents.
ExamplesAMost entries include a code snippet.
LevelBTwo separate feeds (product Changelog vs SDK Release Notes) with no cross-links; features like "V2 Sandboxes" appear in both with different dates.
⚠️ Could improve
A single "breaking changes" page per minor version · Pin the migration guide for 1.6 (there is one for 1.0 only).
Section 02 · Sandboxes

Sandboxes

Runtime-defined containers for untrusted code · gVisor or KVM · 5-min default lifetime, up to 24h · the primitive AgentVetter is built on

Hello, Sandbox

scan_one.py — runs with plain python, no modal run
import 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)
Output
0 {"findings":[{"rule":"curl-pipe-bash","severity":"high", ...
sandbox exit: 137

Quick start

1
pip install modal (or uv pip install modal), then modal setup
2
A Sandbox needs an App: App.lookup(name, create_if_missing=True)
3
exec() commands, read stdout as a stream or .read()
4
Always terminate() — or you pay until timeout
1.6 change: Sandbox.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.
Docs Review — Sandboxes intro page
ClarityAOpens with "why" bullets, then a 10-line runnable example with Python / async / JS / Go tabs.
PrerequisitesBSays you can run it with plain python — good — but doesn't say you need modal setup first or which Python versions work.
ExamplesAEvery sub-feature has a snippet in all four language tabs. Rare and valuable.
LevelAJunior-friendly.
✅ What went well
Four-language tabs · lifecycle events spelled out · exit-code semantics documented (137 = SIGKILL)
⚠️ Could improve
The page still says "default maximum lifetime of 5 minutes" in one place while vendor comparison articles mention a "15-minute idle auto-stop" for Notebooks — the two products should cross-reference · The 1.6 blocking-create semantic isn't on this page, only in release notes

Lifecycle, Timeouts & Exit Codes

CreatedID exists, no compute Scheduledcreate() returns here (V2) Startedexec() allowed Readyonly with a probe Finishedexit / timeout / OOM
ControlDefaultNotes
timeout300 sHard cap; max 24 h. Beyond that: FS snapshot + new Sandbox.
idle_timeoutnoneActive = running exec, stdin writes, or open Tunnel TCP. Catches hung scanners faster than timeout.
exec(..., timeout=)nonePer-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.

Exit code semantics

WhereCodeMeaning
p.returncode0 / nScanner's own exit status (e.g. Snyk uses non-zero for "findings present")
sb.returncode137SIGKILL — from terminate(), timeout, or idle_timeout
sb.returncodeOOMDashboard "Exit Reasons" distinguishes memory-limit vs memory-manager kills (Aug 2026)
sb.poll()NoneStill running (fixed in 1.0.1 — used to be 0)
AgentVetter decision (Aug 4)
Standardise on 137 = "killed by our timeout" so 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.
Docs Review — Lifecycle section
ClarityAThe five lifecycle events with "what you can do at this point" is exactly what orchestration code needs.
ExamplesAReturn-code example shows both process and Sandbox codes side by side.
LevelBDoesn't say what returncode is after an idle_timeout kill vs a hard timeout kill (both 137? presumably). Verify empirically.
⚠️ Could improve
Add an exit-reason enum to the SDK, not just the dashboard chart · Document OOM exit code numerically

Readiness Probes — Replace the sleep()

Modal polls a check inside the Sandbox every interval_ms (default 100) and fires the Ready event. Two kinds:

ProbeSucceeds whenUse for
Probe.with_tcp(port)Port accepts a connectionMCP server over HTTP/SSE, Jupyter, any server
Probe.with_exec(cmd…)Command exits 0File exists, deps installed, docker info
AgentVetter application
Cisco's 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)
Docs Review — Readiness probes
ClarityAMotivated by real pain ("you don't have to build the polling yourself"); both probe types have examples.
PrerequisitesAExplicitly warns that modal.exception.TimeoutError is not the builtin and that the probe timeout doesn't terminate the Sandbox.
ExamplesAIncludes the try/except pattern.
✅ What went well
Best-documented sub-feature on the page.
⚠️ Could improve
No probe for Sidecars yet · interval_ms upper bound unstated

Networking: Three Levels of Egress Control

LevelParameterBehaviourMaturity
Full blockblock_network=TrueDrops all outbound; cannot combine with any allowlist or portsGA
IP-range allowlistoutbound_cidr_allowlist=[...]Any protocol to listed CIDRs. (cidr_allowlist is deprecated.)GA
Domain allowlistoutbound_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 swapsb._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
Inboundinbound_cidr_allowlist=[...]Restricts who can reach Tunnels / Connect TokensGA
Domain fronting caveat (from the docs): two tenants of one CDN share a TLS endpoint, so a Sandbox can send an allowlisted SNI with a different Host header. The allowlist doesn't stop that; a Sidecar proxy that terminates TLS does. ECH connections match only the outer public name.
AgentVetter egress model → Modal mapping
Our spec's static per-scanner domains + dynamic per-run target allowlist maps 1:1 onto 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.
Docs Review — Networking and security
ClarityAThe three-level table at the top is the clearest security doc Modal has. Matching rules shown as a table.
PrerequisitesAStates incompatibilities (block_network vs allowlists) and the domain-fronting limitation honestly.
ExamplesBSnippets exist but there's no end-to-end "observe a denial in logs" example — which is the thing a security product needs to demo.
LevelBAssumes SNI/ECH familiarity.
⚠️ Could improve
Show the exact log line format for a blocked connection · Clarify whether UDP DNS from inside the Sandbox is affected when only a domain allowlist is set

Credential Isolation: Three Ways to Give a Sandbox an API Key

ApproachHowKey visible inside Sandbox?Constraints
Env-var Secret GAsecrets=[modal.Secret.from_name("snyk")] → $SNYK_TOKENYes — any process can read and exfiltrate itWorks with everything; simplest
Outbound Policy Betamodal.experimental.OutboundPolicy().with_header_replacement(domain, secret, headers) → Modal's proxy terminates TLS and injects Authorization outside the SandboxNo — never in env; CA cert at /etc/modal/sandbox-egress-ca.crtHTTP/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 BetaRun a trusted mitmproxy-style container next to the Sandbox; proxy_traffic_via_sidecar relays all 443 traffic to it; Secret injected only in the SidecarNoPre-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)
AgentVetter open decision — this is the one to make
Our spec deferred credential isolation to "Modal's built-in Secret handling". That is the env-var row — the scanned (untrusted) MCP server shares an environment with 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.
Docs Review — Secret Injection page
ClarityAThreat model is stated in the first paragraph. "How it works" explains the TLS-termination mechanism.
PrerequisitesALimitations list is complete and prominent — the incompatibility with domain allowlists is called out.
ExamplesBLinked example exists; the page itself shows only the policy, not a request succeeding through it.
LevelBNeeds TLS/CA understanding to debug client trust failures.
⚠️ Could improve
A decision matrix like the one above (env vs policy vs sidecar) belongs in the docs · Explain how to combine allowlisting + injection (the answer is "Sidecar", but only the sidecars page says so)

Images: Build Once, Name It, Never Block a Scan

build_image.py — run from CI or a cron Function
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")
FeatureWhy 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_syncUp to 50% faster than pip; pin versions — cache may invalidate across SDK bumps
Immutable external tagsfrom_registry("ubuntu:24.04") is cached forever; bump the tag to pull a new upstream
2025.06 Image BuilderModal 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
Pending from Sep 11 session
Mani was to share the current 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.
Docs Review — Images & Named Images
ClarityAThe "Separating Image builds from Sandbox creation" section on the Sandboxes page says exactly why and how.
PrerequisitesBImage Builder Version is a workspace setting that silently changes behaviour; it's mentioned but easy to miss.
ExamplesABuild-then-publish and from_name shown in all four languages.
⚠️ Could improve
No guidance on 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

Snapshots, Runtimes & Sidecars

Snapshots

KindTTLStatus
Filesystem → Image30 d (was ∞; ttl=None to keep)GA
Directory → mountable Image30 dGA (12 Aug)
Memory (processes too)7 d, not extendableAlpha

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.

Runtimes

gVisor (default)VM
IsolationUserspace kernelKVM, own Linux kernel
GPU✅❌
Docker / FUSE / cgroups❌✅ (512 GiB root fs)
Memory snapshotsalphaenabled 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.

Sidecars Beta

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.

AgentVetter post-MVP
The deferred "deeper observability layer" (file/process monitoring beyond egress denials) is a Sidecar job: a mitmproxy sidecar logs every outbound request the scanned MCP server makes, with full URL + body, into Supabase.
Docs Review — Snapshots / Runtimes / Sidecars
ClarityASnapshot retention table and the v1.5 breaking-change warning are front and centre. Sidecars page is long but well sectioned.
PrerequisitesBVM runtime's "may become default" note is buried; GPU-only-on-gVisor is stated twice (good).
ExamplesAFork, persist-by-session-ID, and Docker-in-Sandbox examples are all runnable.
LevelBSidecar resource math on VM runtime (reserve/consume MiB) is fiddly and alpha-ish.
⚠️ Could improve
A snapshot list API (docs admit there isn't one) · Memory snapshots terminate the source Sandbox — surprising; should be a red callout

Resources, Pricing & a Scan-Cost Calculator

Rate (per second)FunctionsSandboxes / Notebooks
CPU, per physical core$0.0000131$0.00003942 (≈3.0×)
Memory, per GiB$0.00000222$0.00000667 (≈3.0×)
GPUSame table for both — T4 $0.000164/s … H100 $0.001097/s … B300 $0.001972/s
Minimum0.125 cores per container; billed max(request, actual) — Sandboxes burst above request
PlansStarter $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.

Scan batch cost estimator
$0.00
as Sandboxes
$0.00
same work as Functions
$0.00
per sandbox-hour

Rates from modal.com/pricing on 5 Oct 2026. Ignores egress ($0.04/GiB beyond 1 TiB free) and the $30/mo Starter credit.

Docs Review — Pricing & Sandbox resources
ClarityBThe per-second rates are clear, but the 3× Sandbox premium is only discoverable by scrolling to a second table on the pricing page — the Sandbox resources doc page doesn't state the multiplier.
PrerequisitesABurst semantics and max(request, actual) billing are explained with a worked sizing procedure.
ExamplesBNo cost example for a typical Sandbox job; the pricing FAQ examples are Functions-oriented.
⚠️ Could improve
State "Sandbox CPU/memory is ~3× Function pricing" on the Sandbox resources page · Add a calculator (the Serverless GPU Calculator blog post is Function-only)

Observability: Proving What Happened in the Box

MechanismWhat 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 systemIncludes 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 integrationsShip 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()
AgentVetter sandbox evidence panel
The demo's evidence panel (sandbox ID · policy phases · denied-egress log · cleanup) maps to: 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.
Docs Review — Logs & metrics
ClarityBCapabilities are scattered across release notes (1.3.2, 1.4.0, 1.5.4, 1.5.5) and changelog posts; there is no "Sandbox observability" guide page.
ExamplesBCLI flags are documented; SDK log APIs have terse signatures only.
LevelAOnce found, straightforward.
⚠️ Could improve
One page: "Observing Sandboxes" (logs, system stream, tags, charts, exit reasons) · Document that Sandbox.logs only covers the entrypoint, prominently

Which Sandbox Configuration?

Does the workload need Docker, FUSE, or nested cgroups?
Yes → runtime="vm" (no GPU)
No → gVisor (default; GPU OK)
↓
Does the code need network at all?
No → block_network=True (Cisco config YARA scan)
Known hosts → outbound_domain_allowlist (+ CIDR for non-TLS)
Open then lock → ["*"] + runtime policy swap (alpha)
↓
Does it call an authenticated external API?
Trusted code → env Secret
Untrusted, no allowlist needed → Outbound Policy
Untrusted + allowlist → Sidecar proxy
↓
Will it outlive 24 h or need reuse?
Ephemeral job → timeout + idle_timeout, terminate
Session → FS/Directory snapshot keyed by session ID
Exclusive per resource → name= (named Sandbox)
Section 03 · Functions, Apps & Scaling

Functions, Apps & Scaling

The declarative half of Modal: decorate, deploy, fan out · autoscaler · web endpoints · storage primitives

Core Primitives & the CLI

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"])
ObjectRole
AppUnit of deployment; owns Functions, Sandboxes, Images. Tags for cost allocation.
Function / Cls.remote(), .map(), .spawn() → FunctionCall ID you can persist
ImageContent-hashed, cached, lazily built; now nameable
Secret · Volume · Dict · QueueManaged via .objects.create/list/delete (1.1.2)
SandboxRuntime container; needs an App
CLIDoes
modal run f.pyEphemeral App; primitive args become flags
modal serveLive-reload (uses "recreate" strategy)
modal deployPersistent; --strategy rolling|recreate; rollbacks on Team+
modal shell sb-xxx / im-xxxDrop into a Sandbox or an Image
modal app/function/server info|stats|logsAgent-friendly introspection (1.6.0)
1.0+ automount change: local packages are no longer auto-included — add them with 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.
Docs Review — Apps, Functions & entrypoints
ClarityAEphemeral vs deployed Apps is explained with the exact CLI matrix.
PrerequisitesAGetting-started walks through modal setup.
ExamplesAPlenty.
LevelABeginner-safe.
⚠️ Could improve
The automount breaking change bites people migrating from pre-1.0 tutorials found via search; a banner on older blog posts would help

Autoscaling, Concurrency & Container Lifecycle

KnobMeaning
min_containersWarm floor (keep_warm removed in 1.4.0)
max_containersCeiling; plan limit 100 / 5000
buffer_containersHeadroom kept warm while active
scaledown_windowIdle 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=TrueFresh container per input (max_inputs=1)
nonpreemptible=TrueCPU only; 3× price
startup_timeoutSeparate 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): ...
1.6.0 breaking: @app.cls() classes may not define __init__. Parameters go through modal.parameter(); setup goes in @modal.enter(). The old __enter__ pattern is long gone.
AgentVetter open decision (Aug 4)
Deployed Apps retry crashed containers with backoff indefinitely; ephemeral Apps stop at a failure-rate threshold. If the fan-out orchestrator is deployed, a broken scanner adapter can crash-loop outside Supabase's view. Decide: ephemeral modal run from the CLI (current), or deployed + retries=0 + explicit status writes.
Docs Review — Scaling out / lifecycle hooks
ClarityAThe autoscaler parameter table is clean; the "6am warm-up cron" example is memorable.
PrerequisitesBRetry semantics for deployed vs ephemeral Apps live on the "Failures and retries" page, not next to the deploy docs.
ExamplesAGood coverage.
⚠️ Could improve
Still many community posts using keep_warm/web_endpoint; the docs' deprecation translator is release-notes-only

Web Endpoints, Servers & Storage Primitives

Serving HTTP

DecoratorWhen
@modal.fastapi_endpoint()One function → one route (web_endpoint)
@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.1Ultra-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.

Storage & coordination

PrimitiveUseGotcha
VolumeWeights, datasets, scratch; $0.09/GiB/mo (1 TiB free)Not safe for concurrent writes to one file; read_only(), sub_path
CloudBucketMountS3/GCS/R2 as a path; OIDC authMount in every container that needs it
DictNetwork KV, cloudpickle valuesEntries expire after 7 days idle; 100 MiB/obj
QueueWork queue, partitionsUnlimited on all plans
AgentVetter stance
Supabase stays the sole system of record — Modal Dict/Volume are never durable state for scans. A Dict is fine for a snapshot-ID cache or the reaper's cursor; nothing a 7-day expiry can hurt.
Docs Review — HTTP & storage guides
ClarityAEach primitive has its own page with limits up front (the Dict page states the 7-day expiry).
PrerequisitesBServers vs web Functions: the new primitive's page doesn't say when to prefer it over asgi_app beyond "ultra-low latency".
ExamplesAStreaming, WebSockets, proxy auth all have snippets.
⚠️ Could improve
A "Server vs Function vs Sandbox" latency/cost table · Volume consistency model deserves a diagram
Section 04 · Endpoints & Training

Endpoints, Training & GPUs

The 2026 products: managed inference from the Model Library, Tinker-compatible training, multi-node clusters

Endpoints: Managed Inference in Two Modes

SharedDedicated
ModelsSelected Library modelsAll Library models + custom weights
BillingPer tokenCompute (GPU-seconds), scale-to-zero
CapacityModal-managedConfigurable autoscaling, --compute-region
AuthProxy Token requiredProxy 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?"}]}'
Affinity routing: one 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.
AgentVetter Phase 5 — reconciler / LLM judge
Cisco's 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.
Docs Review — Endpoints
ClarityAShared vs Dedicated table is the right first thing to show.
PrerequisitesBEndpoint creation is dashboard-first; the CLI path is in release notes. Which models support which mode is only in the Library UI.
ExamplesBcurl only; no SDK-client example on the main page (it's on Integrations).
LevelAClear.
⚠️ Could improve
Per-token prices for Shared Endpoints aren't on the pricing page · Affinity routing deserves a diagram

Training, Clusters & the GPU Menu

GPU$/sec≈ $/hr
T40.0001640.59
L40.0002220.80
A100.0003061.10
L40S0.0005421.95
A100 40 / 80 GB0.000583 / 0.0006942.10 / 2.50
RTX PRO 60000.0008423.03
H100 SXM50.0010973.95
H200 SXM0.0012614.54
B200 / B3000.001736 / 0.0019726.25 / 7.10

Region pinning 1.15–1.75×; non-preemptible 3× (CPU only). GPU Sandboxes are preemptible — design for interruption.

CapabilityNotes
Training quickstartFine-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 & metricsGPU health page, GPU Metrics, modal.Cluster diagnostics
High-performance LLM inference guidevLLM/SGLang/TRT-LLM engine advice; LLM Engine Advisor tool
Not for AgentVetter (yet): our scanners are CPU-bound. The only GPU touchpoint is an optional self-hosted judge — and Endpoints cover that without us owning GPU code.
Docs Review — Training & GPU guides
ClarityAGPU page lists every type with the pricing cross-link; cluster docs explain rank/IP discovery.
PrerequisitesBAssumes you know Tinker's API; no primer.
ExamplesAExamples index has many fine-tuning and multi-node samples.
LevelBAdvanced; appropriate.
⚠️ Could improve
State GPU-concurrency plan limits (10 / 50) on the GPU page, not just pricing
Section 05

Engineering Deep Dives

What Modal has published about how the thing works — and how to read their comparison guides

Under the Hood & Reading the Vendor Guides

Scaling to 1M concurrent Sandboxes
The engineering post behind V2: a rebuilt scheduler and filesystem path that pushed creation rates past 20/s per workspace and demonstrated a million concurrent Sandboxes. This is why create() now blocks until scheduled — placement became the real cost.
gVisor vs microVMs
Modal's isolation story: gVisor intercepts syscalls in userspace (fast start, dense packing); competitors (E2B, Vercel) use Firecracker microVMs. Modal added a KVM "vm" runtime in 1.6 for workloads that need a real kernel — so you now choose per Sandbox.
SWE-bench upstream
Modal contributed a --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.

Security & compliance posture

SOC 2 Type II (all plans) · HIPAA via BAA (Enterprise) · Audit logs (Enterprise) · Data residency and region selection · Customer-supplied encryption keys (alpha) · Restricted Functions / private Environments · Sandboxes cannot touch other workspace resources by default.

The "Best Sandboxes for X in 2026" series

Modal's resources section carries ~10 vendor-authored comparison guides (tool-calling agents, RL, notebooks, IDEs, SWE-bench…). They're accurate about Modal's own limits (24 h, 50k–100k concurrency, GPU list) and reasonably fair on E2B/Daytona/Vercel/Cloudflare — but every one concludes "Modal stands out". Treat as a feature checklist, not a verdict.
Docs Review — Blog & resources
ClarityAEngineering posts are substantive; the GPU Glossary is a standout reference.
PrerequisitesBComparison guides don't disclose their publication cadence or review process.
ExamplesBBlog is narrative; code lives in Examples.
LevelAWell pitched.
⚠️ Could improve
Mark the comparison guides as sponsored/vendor content more clearly · Link each guide's claims to the doc page that backs them
Section 06 · Developer Tooling

SDKs, Agents & Workspace Ops

JS/Go SDKs · the Modal agent skill · agent-friendly CLI · RBAC, budgets, OIDC

Three SDKs, One Agent Skill

SDKStatusNotes
Python modal 1.6.1GAPin minor: modal~=1.6.0; breaking changes only in 1.Y.0. Python 3.10–3.14 (3.14t experimental).
JavaScript modalBetanew ModalClient(); full Sandbox API incl. probes, policies, sidecars. Needs Python-SDK ≥1.2 deployed Apps.
Go modal-client/goBetaSame surface; build logs stream automatically.
AgentVetter CLI is Node
The AgentVetter CLI (Node) currently shells out to the Python sandbox layer. The JS SDK is now capable enough that the CLI could create Sandboxes directly — one fewer runtime in the loop. Worth a spike only after the P0 adapter verification; it's expansion, not correctness.

Built for coding agents

ToolWhat
modal skills install / updateInstalls Modal's foundational agent skill (1.5.0) — keep Claude Code / Cursor on current API names
modal changelog --since=1.5Query 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|logsStructured introspection; --json with normalised keys
modal curlHit authenticated endpoints without juggling proxy tokens
pty=TrueSandbox 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.

Docs Review — JS/Go SDKs & agent tooling
ClarityAFour-language tabs across the Sandbox docs mean the JS/Go story is as discoverable as Python.
PrerequisitesBJS/Go are "Beta" but the maturity page doesn't list which Sandbox features lag (e.g. logs API).
ExamplesAAgent examples are complete repos.
LevelAGood.
⚠️ Could improve
Publish the skill's contents in the docs so non-CLI users can read it · A JS/Go feature-parity matrix

Workspace Ops: RBAC, Budgets, Identity

CapabilityDetailPlan
Environmentsdev/staging/prod split inside a workspace; Restricted Environments with configurable default roles → private envs (Aug 2026)All
RBAC + User groupsEnvironment roles per member/service user; groups synced via SCIM (Sep 2026)Team+
BudgetsWorkspace and per-Environment monthly caps; usage limits redesigned Aug 2026Team+
Billing APIsWorkspace.billing.report()/summary()/rates(), modal billing … — cost by App tag, by resourceTeam+
OIDC identity tokensinclude_oidc_identity_token=True → $MODAL_IDENTITY_TOKEN; federate to AWS/GCP without static keys; works in Sandboxes & SidecarsAll
Service users & tokensmodal token new --expires-in; named proxy tokens; modal token infoAll
SSO / SCIM / audit logsOkta, Entra, SAML (XML metadata upload Sep 2026); audit logsEnterprise
Static-IP ProxiesEgress via fixed IPs, region-selectable (Aug 2026); usable from Sandboxes and SidecarsTeam+
NotificationsSlack per app/environment, in-app follows (Sep 2026)Beta
AgentVetter multi-tenancy (deferred)
When RLS/auth lands, Modal's side is already there: one Environment per tenant tier, Environment Budgets to cap a runaway batch, and OIDC tokens so the Sandbox writes to Supabase Storage via a short-lived federated credential instead of a long-lived service key in a Secret.
Docs Review — Workspace & account settings
ClarityAEach feature has a page; plan gating is mostly stated.
PrerequisitesBSeveral items are "Beta" with no listed limitations (SCIM, user groups).
ExamplesBOIDC guide has AWS examples; GCP is thinner.
⚠️ Could improve
A single plan-feature matrix in the docs (it exists only on the pricing page)
Section 07 · Modal × AgentVetter

Modal × AgentVetter

Every locked AgentVetter decision that touches Modal, re-checked against the October docs

Decision Audit: Spec vs Current Modal

AgentVetter decision / assumptionModal feature todayVerdictAction
Ephemeral Sandbox per scan, prebuilt image, content copied at runtimeNamed Images + Image.from_name(); sb.filesystem.copy_from_local() (2.5× faster since 1.5.3)HoldsPublish image with a dated tag; migrate any sb.open() usage — removed in 1.6
Static per-scanner + dynamic per-target egress allowlistoutbound_domain_allowlist (beta) with denials loggedBetterReplace 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 proxiesRevisitDecide env vs policy vs sidecar (slide 2.6). Policy and domain allowlist are mutually exclusive.
Fixed sleep before Cisco MCP live scanReadiness probes (TCP / exec)BetterAdopt; handle modal.exception.TimeoutError → unreachable
Hard timeout → exit 137 = "killed by us"Unchanged; terminate(wait=True) needed to read itHoldsAdd idle_timeout; verify 137 on idle kill empirically
Sandbox tagging for orphan detectiontags= at create, Sandbox.list(tags=)HoldsAdd a 10-min reaper Function (slide 2.9)
Sandbox default 5 min lifetime; .map() fan-outUnchanged. V2: create() blocks; ResourceExhaustedErrorAdjustTreat create failures as a 5th status (not_scheduled?) — this is the "5th error status" fixture gap
Sandbox cost assumed ≈ Function costSandbox CPU/mem ≈ 3× FunctionsWrongBudget with the calculator; request 0.25–0.5 cores and let bursting cover peaks
Node.js in base image for npx tessl + Node MCP serversNo changeHoldsKeep; 2025.06 Image Builder reduces dep conflicts
Deeper observability (file/process) deferredSidecars + proxy_traffic_via_sidecar; verbose=TrueUnlockedPost-MVP: mitmproxy sidecar for full request capture
1 AgentVetter spec & STATUS.md (neomatrix369/agentvetter) · 2 Modal power-user guide (4 Aug 2026 session) · 3 Sandboxes · 4 Networking · 5 Release notes · 6 Pricing

The Scan Path on Modal, End to End

agentvetter CLI (Node) scan → batch_id + run_ids Orchestrator Function .map(run_ids) · ephemeral App Sandbox (gVisor) Image.from_name · timeout 600 · idle 120 outbound_domain_allowlist[scanner + target] probe → exec snyk / cisco / tessl tags{scan_run_id, batch_id} Supabase (system of record) findings · scan_run_scanners · Storage Modal Secrets / Outbound Policy SNYK_TOKEN · CISCO key · TESSL_TOKEN Reaper (cron 10 min) Sandbox.list(tags) → terminate Dashboard (Next.js) Realtime heatmap · evidence panel Evidence: sb.object_id · get_dashboard_url() · container logs --source system (egress denials) · returncode 137 on cleanup
What's new on this diagram vs the August .tldr: readiness probe before the live-server scan; 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.
Still open (ask before locking): ① ephemeral vs deployed orchestrator; ② env-Secret vs Outbound-Policy vs Sidecar; ③ whether a V2 ResourceExhaustedError becomes the 5th scan_run_scanners status; ④ snapshot TTL for the scanner image (named Images don't expire; FS snapshots do).
1 AgentVetter architecture .tldr & ARCHITECTURE.md (C4 L1/L2) · 2 Sandboxes · 3 Scheduled Functions · 4 Secret injection
Section 08

Quick Reference

Sandbox cheat sheet · rename map · every link in one place

Sandbox Cheat Sheet (SDK 1.6.1)

Create

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": ..})

Lookup

Sandbox.from_id(id) · Sandbox.from_name(app, name)
Sandbox.list(app_id=, tags=)

Use

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()

CLI

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

Rename & Removal Map (what old tutorials get wrong)

You'll seeUse insteadSince
modal.Stubmodal.App1.0.0 (now AttributeError)
keep_warm=, concurrency_limit=min_containers=, max_containers=removed 1.4.0
@modal.web_endpoint@modal.fastapi_endpoint1.0
max_inputs=1 on functionsingle_use_containers=True1.3.0
cidr_allowlist=outbound_cidr_allowlist=1.4.3 (deprecated)
sb.open()/ls()/mkdir()/rm()/watch(), FileIOsb.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 bootstrapmodal endpoint create1.6.0
Environment.membersEnvironment.roles1.5.3
Snapshots kept foreverttl=None to opt out of 30-day expiry1.5.0
Function.from_name(app, "Cls.method")Cls.from_name(app, "Cls")1.4.0
modal deploy project.appmodal deploy -m project.app1.4.0

Our own August guide already had the first three. Everything below that line is new since.

All Links

1 All links verified reachable on 5 Oct 2026 via modal.com navigation

Start Building

$30/month free compute on Starter · 100 containers · 10 GPU concurrency

Sandboxes
pip install modal && modal setup
Sandboxes guide
Functions
modal run hello.py
Getting started
Endpoints
modal endpoint create
Endpoints guide

Next for AgentVetter: share the modal_app.py install chain → pin scanner versions → decide the credential-isolation option → verify adapters against real output (P0).

v1  ·  Built from 30+ Modal guide pages, release notes 1.0→1.6.1, changelog, pricing, and the AgentVetter session history  ·  34 issues tracked  ·  5 October 2026