| name | remote-compute-modal |
| description | Run GPU jobs on the user's own Modal account via host.compute.create('byoc:modal', ...). Covers the create→submit→wait_for_notification flow, the compute_provider kernel for env setup, image/volume resolution, and the two approval cards. Load once you've decided to dispatch to Modal. |
| license | Apache-2.0 |
You're dispatching to the user's Modal account: containers spin up in Modal's
cloud, on hardware you name in plain terms (gpu/cpu/memory/timeout), under
their workspace, on their bill. That's the reason there are
two approval cards rather than one, and the reason the env-setup surface is
a separate kernel rather than something you can call inline from the
control-plane kernel: each card is the user consenting to a specific,
bounded use of their credential, and the architecture keeps those grants
legible by keeping the surfaces apart.
If compute.create('byoc:modal', …) returns unknown provider 'byoc:modal',
Modal isn't enabled in this install — ask the user to enable it under
Settings → Compute → Modal (or surface the prompt; you can't enable it for
them).
For first-time environment setup, see env-setup.md in this skill
directory — it walks through driving the compute_provider kernel to
build and record images.
Two timeouts, one timeline
Modal has two deadline timers. Both live INSIDE the sandbox, and neither
can cost you the outputs of a job that ran. The timeline every job runs
on:
- Container life starts at sandbox creation — staging the inputs counts
against it, so a big upload spends container time before the job runs.
- The job runs. If it exceeds its own budget (the job timeout below), it
is TERMed there.
- Near end of container life — one harvest margin before the provider
destroys the sandbox — the in-sandbox harvest watchdog TERMs
whatever is still running. It runs on the sandbox's own clock, so a
wrong desktop clock or a slow upload can't make it fire late.
- After any TERM there is a grace window for checkpoint-on-TERM handlers
to flush, then the process group is stopped.
- Whatever is under
./out/ (plus the logs) is tarred and staged
UNCONDITIONALLY — on success, timeout, failure, and crash alike — and
harvested back into the workspace. Staging is unconditional; delivery
isn't. If the stream is refused or gives up (result_rejected,
harvest_failed), the staged copy waits on the sandbox, and a third,
post-job timer — the idle watchdog — terminates it after ~30 min
of inactivity. See "When the job fails".
The container timeout (provider_params.modal.timeout) is how long
the sandbox lives; omitted, it fills from the Settings default for this
provider (Settings → Compute → Modal; ceiling: Modal's 24 h platform
lifetime, minus the staging margins — 85,500 s). The job timeout
(timeout_seconds on submit_job) is an optional runaway guard for one
job; omitted, it defaults to the container's remaining life minus the
harvest margin. Name one when you know the job's budget — a hung job then
costs that budget, not the whole container, and the warm container
survives for the next submit.
A deadline-ended job lands as status: 'timed_out' — not a generic
failure — with its partial outputs already harvested, plus a note saying
the deadline (not the workload) ended the run and suggesting the remedy:
a larger timeout_seconds, or resuming from a harvested checkpoint. A
deadline ends the run, never the results.
Two surfaces, one provider
You reach Modal two ways. They share confinement and credentials but answer
different questions, and confusing them is the most common way to waste a
turn.
host.compute.create('byoc:modal', provider_params={'modal': {...}}) is
the job surface. It runs in the repl tool — same as
remote-compute-ssh — and is what you use for anything with
inputs/outputs you want harvested into the workspace, anything on a GPU, and
anything long enough that you'd want to reattach if the daemon restarts. The
call itself is a stateless constructor — the tier card (provider, image,
1× A100-40GB · 8 CPU · 32 GB, container timeout, volumes) and the actual
Sandbox creation both happen on the first submit_job().
submit_job/result/call_command/attach_job/close then work exactly
as for SSH: .result() is non-blocking — the daemon's poller probes the
sandbox, harvests out.tar.gz into hpc/<jobId>/, and emits a
compute_done notification when done. To wait, exit the cell after
submit_job and use the wait_for_notification brain-tool. What reaches
the remote is what was in the workspace files you named, and what comes
back lands under hpc/<jobId>/ through the same hardened extractor.
compute_provider({'provider': 'modal', 'code': '…'}) is the environment
surface — a Python shell with the user's Modal SDK already authenticated,
running in its own confined process. Use it to prepare compute: build
container images JIT in the user's workspace, populate model-weight volumes
by running the downloads on Modal's own infrastructure (so wide-internet
fetches never traverse the local allowlist), check what assets already
exist, run a short CPU smoke probe. Its first cell in a session fires the
kernel card — "environment setup — build images, populate volumes, CPU
probes ≤30 min; GPU jobs ask separately" — and once granted, subsequent
cells in this kernel's lifetime (idle-timeout ~15 min) run without further
prompts. The kernel will reject gpu= on Sandbox.create and clamp
timeout to ≤30 min — the kernel's own probe budget, separate from the
job surface's container timeout. It's a redirect to the job surface, not
a fence; you've already been handed the credential. Terminate every
sandbox you create here — sb.terminate() in the same cell, in a
try/finally if you exec in between (build_env's hydrate models
this). Nothing reaps it mid-session: the idle timeout kills the
kernel, never your sandboxes — the ≤30-min clamp only bounds how long
a forgotten one bills.
The shape of a good run is: read what's already known about this workspace,
decide whether the env you need exists, build it via the compute_provider
kernel if it doesn't, then run the actual job through compute.create().
The two cards appear at most once each per session — and on a warm second
session with a project-level grant, neither.
Workflow
Every host.compute.* call here runs via the repl tool; the
compute_provider kernel is reached via the compute_provider tool.
Neither is the python tool. All three share your workspace directory but
not memory — pass data through files.
Start with compute_details({provider: 'byoc:modal', mode: 'read'}). Below
the orientation line is the per-workspace ledger — ### env:<name>@<spec_sha>
blocks recording images that already exist in this user's Modal workspace,
the volumes they pair with, and any free-text notes a previous session left.
The spec_sha is the content hash of the env's source file in this binary,
so a ledger entry whose hash matches is one you can use without rebuilding:
pass its image ref straight to compute.create() and the kernel card never
appears. A mismatch means the env definition changed in this release — the
old image still works but isn't what the current env file describes, so
treat it as absent.
If what you need isn't in the ledger, build it in a compute_provider
cell. Two helpers are pre-bound there alongside modal and app:
list_envs() → {name: META, …, '_envs_dir': path} for every bundled
env (packages, GPU tier, secrets it needs)
build_env(name, *, hydrate=False) → builds the image, returns
{'image': 'im-…', 'spec_sha', 'volumes', 'env', 'hydrate'}
To inspect what an env actually installs (the modal.Image chain) before
building, read the source from the control-plane kernel (a repl
cell) — it's a skill asset:
print(host.skills.read('remote-compute-modal', 'envs/proteomics_jax_gpu.py')['content'])
Don't find for it from bash — the path differs across builds.
need = {'transformers', 'torch'}
envs = list_envs()
print({n: m for n, m in envs.items()
if not n.startswith('_') and need <= set(m.get('packages', []))})
r = build_env('proteomics_gpu', hydrate=True)
print(r['image'], r['spec_sha'], r['volumes'])
Carry r['image'] (the im-… reference, NOT the env name) back to the
control-plane kernel (a repl cell) and pass it to
provider_params.modal.image — and, for a bundled env, its NAME as
provider_params.modal.env: the host then reads the env's
META["egress_domains"] from the SHIPPED file at every submit, so the
declaration survives rebuilds (a pointer into the shipped catalog, not a
power). The two kernels share a working directory but not memory, so the
simplest handoff is a workspace JSON file:
import json; json.dump(r, open('built_env.json', 'w'))
Append a ### env:<name>@<spec_sha> block to compute_details so the
next session reads the im-… ref straight from there and skips the
compute_provider cell entirely. Pass r['image'] (the im-… ref) to
provider_params.modal.image. The adapter will resolve a bare env name
from the ledger as a fallback, but that's brittle — the ledger entry can
be stale or absent. Treat the build as its own task with its own validation;
don't fold it into a job submission. See env-setup.md for ad-hoc patches
and gated weights.
For a quick inline call (no inputs, output is just stdout), use
submit_job with no inputs. The compute_done notification payload
carries status / exit_code / featured_files, plus error_kind,
system_hint, and deadline_fired when set — read those disclosures
from the notification itself (a deadline-truncated rc-0 job announces
partial outputs there). For stdout_tail, re-enter the kernel and read
it from .result() after the notification arrives (call_command is SSH-only — it bails on a byoc:
handle):
import json
r = json.load(open('built_env.json'))
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': r['image'],
'env': 'proteomics_gpu',
'gpu': 'A100',
'volumes': r['volumes'],
}})
j = c.submit_job(
intent='load esm2_t6 on GPU',
command='python -c "from transformers import EsmModel; import torch; '
'm = EsmModel.from_pretrained(\\"facebook/esm2_t6_8M_UR50D\\").cuda(); '
'print(torch.cuda.get_device_name())"',
timeout_seconds=300,
)
print('JOB_ID:', j.job_id)
Then exit the cell and use wait_for_notification. When the
compute_done notification arrives, its payload has status /
exit_code / featured_files, plus error_kind / system_hint /
deadline_fired when set. For stdout_tail, re-enter the kernel:
print(c.attach_job('<JOB_ID>').result()['stdout_tail'])
c.close()
For a full job with inputs and harvested outputs:
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': r['image'],
'env': 'proteomics_jax_gpu',
'gpu': 'A100-40GB',
'cpu': 8,
'memory': 32768,
'volumes': {'/cache': 'af2-params'},
'timeout': 3600,
}})
job = c.submit_job(
intent='colabfold on target.fasta — 1× A100, ~40 min',
command='colabfold_batch target.fasta out/ --num-recycle 3 --model-type alphafold2_ptm',
inputs=[{'src': 'target.fasta', 'dst_filename': 'target.fasta'}],
outputs=['out/ranked_0.pdb', {'glob': 'out/*.json', 'visibility': 'featured'},
{'glob': '*.log', 'visibility': 'hidden'}],
timeout_seconds=2700,
)
print('JOB_ID:', job.job_id)
Then wait_for_notification — the compute_done payload includes
featured_files, so you can save_artifacts(payload['featured_files'])
directly without re-entering the kernel. If you want the full dict
(stdout_tail / stderr_tail / job_wall_s):
r = c.attach_job('<JOB_ID>').result()
save_artifacts(r['featured_files'])
c.close()
r['job_wall_s'] is the seconds run.sh ran on the remote — the actual
GPU/compute duration.
timeout is the container's lifetime; timeout_seconds guards one job
inside it. Both are optional: timeout fills from the Settings default,
timeout_seconds from the container's remaining life minus the harvest
margin. Name a timeout_seconds when you know the job's budget — a hung
job then costs that budget and the warm container survives. At either
deadline the job is TERMed, given grace, and its outputs are staged and
harvested as on success — the job lands as status: 'timed_out'
(deadline-terminated, outputs harvested), not a generic failure.
inputs= stage flat into the workdir root. dst_filename is a bare
filename — 'inputs/gfp.fasta' is rejected at submit (the file would land
at /work/gfp.fasta, not where the command expects). src can be a path;
dst_filename can't. Omit it to default to basename(src). Need a dir
layout? mkdir -p it inside command=.
Sizing inputs= — and when to use a Volume instead. Inputs are
stdin-streamed through a confined path at ~0.65–0.81 MiB/s effective
(measured) — budget staging time from that: 1 GiB ≈ 20–26 minutes of
upload before the job starts, spent from container life. The host
budgets staging from input size with a planning rate it cannot verify
(upload speed isn't controllable), so a slower-than-planned upload never
endangers the harvest — the watchdog's absolute-clock backstop still
protects the staging window; the job just gets less runtime than
nominal. If an upload dies outright, the submit fails LOUDLY with a
transport error and no phantom job. Practical sizing: code, configs, and
small data are what inputs= is for; it is workable up to its 1 GiB
per-submit cap (slow but fine — budget the ~20–26 min of staging near
the cap). Anything above 1 GiB is refused at submit time, so beyond it a
Volume is the only path — and the better one well before that. Volumes
(Files-tab import, or a compute_provider cell; mount via
provider_params.modal.volumes) persist on Modal disks across jobs and
cost zero upload per submit.
Checkpoint long jobs as they run. Write progress to ./out/ (or a
Volume) periodically: at either deadline the workload gets TERM, then a
grace window for checkpoint-on-TERM handlers, and whatever is under
out/ is staged and harvested unconditionally. A job that checkpoints
loses at most one interval to a deadline — never the run. Keep ./out/
checkpoints SMALL (≈ ≤100 MB compressed): the harvest stream back to the
host runs in a bounded (~2 min) window, so a multi-GB ./out/ risks
harvest_failed even though staging succeeded — large checkpoints
belong on a Volume, with only small summaries under ./out/.
Only ./out/ is harvested. The wrapper tars out/ + stdout.log +
stderr.log — nothing else. outputs= globs are a post-harvest filter
(featured/hidden), not a what-to-collect directive. If your tool
writes to cwd, a Volume mount (e.g. /atlas), or $HOME, end command=
with cp -r <results> out/ (or point the tool's -o/--output at out/).
A successful job with an empty out/ returns only the log paths in
output_files plus a system_hint telling you so.
command= is interpolated into a run.sh and run via bash run.sh.
For anything beyond a single program-with-args — nested quotes, inline
python -c "…", heredocs, pipelines — write the script to a workspace
file and ship it via inputs=, then command='bash script.sh' or
command='python script.py'. Multi-layer shell escaping inside
command= is the most common cause of syntax error near unexpected token failures.
provider_params.modal uses Modal's own kwarg names — Modal's docs are
the docs. The adapter resolves the strings to SDK objects (env name → its
im-* from the compute_details ledger; volume name → Volume.from_name)
and passes the rest through to Sandbox.create(**params). An absent
timeout fills from the Settings default before the tier card renders,
so the card shows the real lifetime. A timeout above Modal's 24 h
platform cap or out-of-range hardware is rejected with invalid_request
before any helper spawns; nothing is silently clamped.
The image-reference rule
image is always a string. A modal.Image object built in a
compute_provider cell cannot cross to the control-plane kernel — they are
separate Python processes under separate confinement invocations, and a live
SDK handle is meaningless on the other side. So the pattern is:
img = (modal.Image.from_registry('nvidia/cuda:12.4.1-runtime-ubuntu22.04',
add_python='3.11')
.pip_install('chai_lab==0.6.1'))
img.build(app)
print(img.object_id)
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': 'im-7f3a2e…',
...
}})
The adapter validates an im-* literal via Image.from_id(), which fails
closed on a foreign or fabricated id, so a typo surfaces as not_found
before any GPU spins. An env name is looked up in the ledger first
and resolved to the recorded im-*; on a miss the adapter returns
image_build_failed with copy pointing at
compute-env-setup rather than triggering a build inline.
What latency to expect
Measured against real Modal during validation — set timeouts and user-status
messages from these, not guesses:
| phase | typical | notes |
|---|
Image.from_registry(..., add_python=...).pip_install(...).build(app) | 130–360s | one-time per im-*; runs on Modal's CPU build infra, not your tier |
compute.create() (stateless ctor) | <1ms | just returns a handle — no container, no card |
first submit_job() on a handle, A100 | ~18–30s cold start + job runtime | creates the container (image pull + boot); raises the tier card |
first submit_job() on a handle, H100 | ~20–25s + job runtime | similar; queue wait can add when capacity is tight |
subsequent submit_job() on same handle | ~5–10s + job runtime | reuses the warm container — weights/caches stay hot |
So budget ~3–6 min for a fresh env's first GPU result, and ~5–10s per
subsequent submit_job() on the same handle. If a first cold start is
past ~90s, that's queue wait, not a hang — surface it rather than retrying.
When the user gives you a budget
A user who says "stay under eight GPUs" or "keep it to a hundred at a
time" is giving you a number that the prompt alone can't enforce. You'll
write it into the orchestrator's instructions, but the sub-agents you
delegate to start with fresh context — they never see that line, and
each one will reasonably try to use as much compute as its own task
seems to warrant. Across a wide fan-out that drifts well past whatever
the user had in mind, and the first sign is usually the bill.
host.compute.set_concurrency_limit(k) exists so the user's number
becomes a property of the session rather than a sentence in a prompt.
Call it once before delegating; the daemon stores it against the session
root, counts every sub-agent's live job against the same k, and
quietly holds any submit that would put the session over (the SDK
retries with backoff under the hood). Sub-agent code is unchanged — the
hold sits below submit_job, not in the agent.
Choosing k has one constraint beyond the user's intent: each provider
also has its own ceiling, and that ceiling refuses rather than queues.
A session limit above it doesn't fail, it just stops being the binding
constraint — submits past the provider's own ceiling error instead of
waiting. host.compute.status() returns both your k and the
provider ceilings, so you can pick a value that actually queues. When
the user hasn't given a number, leaving the limit unset keeps today's
behaviour; set one yourself only if a fan-out is wide enough to threaten
the provider cap and you'd rather queue than fail.
Warm reuse, reattachment, and close()
One handle = one container. The first submit_job() creates it;
subsequent calls reuse it warm (weights/XLA cache stay hot). The poller's
harvest does not terminate the container — it runs until c.close(),
~30 min of inactivity (the sandbox's PID-1 idle watchdog — a kill,
not a harvest: anything unharvested goes with the container), or its
container timeout, whichever comes first. Until then a finished — or
failed — job's container bills idle. Every handle ends with
c.close() — after its last job, not between jobs, and not while its
sandbox holds the only copy of unrecovered outputs
(result_rejected/harvest_failed — see "When the job fails"). Close
once the final compute_done has arrived and the harvest is confirmed;
if the next job on this handle is imminent, or the user asked to keep
the container warm across jobs, leave it open and close when the
sequence ends. Near end of life the harvest watchdog stops any
running job early enough to stage its outputs, so even the last job
comes back harvested. A warm container is reused only when its
remaining life covers the next job's deadline plus margin; otherwise
the submit transparently
creates a fresh sandbox (one more cold start, same handle) and says so
in a [note] line on the submit result.
Sequential only: each submit wipes /work, so call job N+1's
submit_job() after job N's compute_done notification arrives; for
parallel jobs use separate handles. A second create() with different
{gpu, cpu, memory} is a separate container and a separate card. The
detached wrapper survives a Claude Science daemon restart — on restart you'll see
"1 job reattached" rather than a cold start.
Multiple parallel jobs (separate handles): submit all in one repl
cell, then loop wait_for_notification → act on every entry in
notifications (several may arrive at once) → repeat until it returns
{status:'error'} (= none left). Don't poll .result().
Before you finish the task: every handle closed (unless the user
asked to keep one warm, or its sandbox holds unrecovered
result_rejected/harvest_failed outputs), and no sandbox of yours
still alive in the compute_provider kernel
(modal.Sandbox.list(app_id=app.app_id) shows what's running). Tear
down job-surface containers with c.close(), never a raw
sb.terminate() — only close() consults the host's
recoverable-outputs gate. Reserve sb.terminate() for sandboxes you
created in the kernel, and only ones you recognise from this session —
warm containers from other Claude Science installs share the same app.
When the job fails
A failed job's container stays up on purpose, so you can fix and
re-submit warm — but "stays up" has a clock on it: the in-sandbox idle
watchdog terminates it after ~30 min of inactivity, and reading files
off it does not reset that clock. If you aren't re-submitting on this
handle now, c.close() it and diagnose from the harvested tails and
outputs below — except on result_rejected and harvest_failed,
where the sandbox holds the only copy of the output; read those
entries before you touch close(). For every other kind, closing
loses only what the harvest didn't catch (a core dump, a verbose log,
a residency:'remote' output) — and .download() can't save it here:
scp is SSH-only and errors on a byoc: handle. Read any such file off
the live sandbox from a compute_provider cell BEFORE you close: find
it with modal.Sandbox.list(app_id=app.app_id, tags={"claude-science-job": "<job_id>"}) — the tag names the job
whose submit cold-started the container: your failed job, unless you
chained submits on a warm handle (each fresh sandbox announces itself
in a [note] on its submit result) — then sb.exec("cat", "<path>")
to pull the bytes.
Read r['exit_code'], r['stdout_tail'], and r['stderr_tail']. A job
that hit a deadline lands as status: 'timed_out' with its partial
outputs already harvested into hpc/<job_id>/ — check stdout_tail for
progress and resubmit with a larger timeout_seconds (or resume from a
harvested checkpoint) only if the work was actually cut short. The
errors that come back as kind rather than a non-zero exit code map cleanly
onto where to look:
image_build_failed — Modal's layer build errored: a yanked PyPI version,
a base-image pull failure, a pin that no longer resolves. The build-log
tail is in the compute_provider cell where you ran build_env(); fix the
env file and rebuild there. No image reference was produced, so the ledger
stays clean. If you passed an env name to create() without ever building
it, the name is valid but no image is in the ledger yet — build_env() it
first.
unauthorized — token rotated or revoked. The compute_provider kernel
will have already exited on the first 401; the next cell respawns and
re-reads ~/.modal.toml, so the user fixing their token is the whole fix.
quota_exhausted — workspace quota, concurrent-GPU limit, or spend cap.
For the concurrent limit, first free what you hold: c.close() finished
handles (not one holding unrecovered outputs — see below) and terminate
kernel sandboxes you're done with — idle warm containers count against
it. A spend cap isn't freed by closing — accrued spend stays accrued. If
it still trips, surface the copy verbatim and pause; raising the limit
is the user's billing decision.
rate_limited — a request-rate throttle on the token, not capacity:
closing containers frees nothing here. Back off ~60s and stagger
fan-out submissions, per the copy.
ownership_mismatch — another install sharing this workspace token adopted
the warm sandbox first. The adapter cold-starts a fresh one; nothing for you
to do beyond noting the banner.
result_rejected (compressed_cap_exceeded) — output archive larger than
the consented cap. Nothing was written to the workspace and the sandbox is
still warm with out.tar.gz intact; the user gets a raise-and-retry
affordance that re-streams without re-running. The sandbox holds the only
copy of the output — do not c.close() until it's resolved, and
surface it to the user now, not at end of task: the Compute panel lists
the job as recoverable for 24 h, but that listing only blocks
Claude Science-side kills (a queued close() is deferred ~15 min, then
executes) — the in-sandbox idle watchdog still terminates the sandbox
after ~30 min of inactivity, only copy included.
harvest_failed — the output stream gave up after retries; the warm
sandbox likewise holds the only copy. Same rule: no c.close() until
the outputs are recovered — and the recovery path is the user's, not
yours: they retrieve from the recovery list in the Compute panel
(.download() errors on a byoc: handle, and reuse on it is gated).
Same clocks as above: surface it immediately.
input_changed — a submit input was rewritten (or deleted/swapped) between
validation and staging: the transport re-hashes the bytes it actually
uploads and compares them to the sha256 captured at validation time.
Nothing was uploaded and the job never started. Re-run submit_job() so
the input is re-validated; if it keeps tripping, something is still writing
that file — wait for the writer to finish before resubmitting.
A plain non-zero exit_code with logs is the science tool failing on
inputs — same diagnosis as on SSH.
What to record
After a successful build, append the ### env:<name>@<spec_sha> block to
compute_details — env-setup.md §"Recording the result" has the snippet.
That ledger is the durable record; don't also write the same facts to
memory. Do NOT record build timings, validated: dates, or sha256
confirmations — spec_sha already encodes staleness and the rest is
noise. Gotchas you hit (a documented invocation that failed, a flag you
had to add, a quota you bumped into) are worth a free-text line.
GPU tier reference
gpu value | device | VRAM |
|---|
"A10G" | NVIDIA A10 | 24 GB |
"A100" | A100-SXM4-40GB | 40 GB |
"A100-80GB" | A100-SXM4-80GB | 80 GB |
"H100" | H100 80GB HBM3 | 80 GB |
Each bundled env carries a gpu_default in its META — what its author
sized for the typical workload. Start there; reach for a bigger tier when
you have a concrete reason (the model is documented as needing >40 GB, the
last attempt OOM'd), not as a hedge. The approval card shows the tier you
picked, but the grant is keyed on provider only, so once approved for this
conversation a tier change doesn't raise a new card — the user is trusting
your judgment on sizing after the first approval.
Volumes
Pass volumes={"/weights": "<vol-name>"} in provider_params.modal. The
mount is a symlink to /__modal/volumes/<id>/ — always reference it by the
mount path, never the resolved target.
The Files tab lists byoc:modal alongside SSH hosts: at / it shows
Volumes as top-level directories, and inside one you can browse, import,
and download files (same pane as SSH). The path's first segment is the
Volume's name, not a mount path. For scripted listing use a
compute_provider cell:
modal.Volume.from_name('<name>').iterdir('/', recursive=False).
Modal Environment (Settings → Compute → modal → Environment)
Optional, beside the Default app setting. A Modal Environment is
the workspace namespace apps, images, volumes and sandboxes live in —
with the app, one of the two coordinates every Modal name resolves
under. Claude Science resolves every named object it touches (App.lookup,
Volume.from_name) in the configured Environment, and the
compute_provider kernel is started inside it, so build_env() /
HYDRATE and an env file's own modal.Volume.from_name(...) calls
land there too. Blank — the default — sends no environment at all and
Modal uses its own default (normally main, or whatever
~/.modal.toml says). You cannot set or change it: it is
host/UI-written (compute_providers.modal_environment), never a
provider_params.modal field (the schema rejects environment /
environment_name outright), and the ledger cannot reach it.
Which Environment and App am I in?
A kernel binds its coordinates at spawn — Environment, app, egress
posture — and never re-reads them, while the user can change the
Settings at any time; a change only reaches what gets created next.
compute_provider_config() is how you find out where you actually
stand:
compute_provider_config()
Call it before anything expensive. If it returns, trust every value in
the dict (environment: None means Modal's workspace default;
app_name is already resolved). If it raises, the Settings moved
after this kernel started and there is exactly one remedy: close this
compute_provider kernel and open a fresh one, which is born into the
new configuration. build_env() makes the same check before building,
so a stale kernel cannot spend twenty minutes on an image in a
namespace the next submit_job() — which always follows the live
Settings — will never look at.
Sandboxes follow the same birth rule. A fresh submit_job() takes the
Settings of the moment it creates its sandbox; a warm-reuse submit
keeps the Environment, app and egress fence its sandbox was born under
(see Warm reuse, reattachment, and close()). So a Settings change
— an egress change included — reaches your next fresh sandbox, never a
warm one, and nothing ever moves: images, volumes and running
sandboxes stay where they were created, reachable again the moment the
setting points back.
Network egress from the job sandbox
What the job container can reach on the network is a per-provider setting
(Settings → Compute → modal → Network Restrictions), not something you
choose per submit. The host stamps it onto every new sandbox as Modal's
outbound_domain_allowlist (or block_network). Four states:
- Not configured (
NULL, the default for new connections and for any
provider enabled before this setting existed) and Unrestricted — the
sandbox has open internet. The byoc_submit card shows no egress row.
- Allowlist — the sandbox may only open outbound connections to a
list the HOST computes at approval time:
(mirror of the user's local sandbox allowlist ∪ a built-in seed of pull-only wheel indexes — download.pytorch.org, pypi.nvidia.com — when the policy mirrors) ∪ (the policy's extra domains) ∪ (the env's declared domains). A mirror:false policy is never seeded (it is the
user's explicit "exactly my list"). The card shows the exact final
list (Egress limited to N domains) with each part attributed; only
portable domain patterns are mirrored (a local bare * or interior
wildcard is dropped and disclosed, never sent to Modal where it means
something else); and when there is no local allowlist to mirror
(network isolation off or unavailable) the resolution is unrestricted
and the card says so. Two hosts are never seeded:
storage.googleapis.com (hydrate the weights volume instead) and the
write-capable Hugging Face hub/CDNs — an env that pulls from HF at
job time declares huggingface.co + *.hf.co itself.
- No network — the user chose to create every new job sandbox with
block_network: all outbound connections refused. This is a kill
switch, decided before any allowlist input is even read. The env's
egress_domains / the ledger's egress: line are ignored under
it — there is deliberately no channel, yours or an env's, that can
open a hole in it — and the card says
"all outbound network is blocked (provider policy: No network)".
You influence exactly one input to that merge: which env declaration
applies. META["egress_domains"] is the list of hosts the env's
documented job-time commands need (e.g. api.colabfold.com for the
default ColabFold MSA server). For a bundled env, pass its NAME as
provider_params.modal.env (or let the ### env: ledger block name
it): the host reads the declaration from the SHIPPED file, so it
survives every rebuild and can only select among shipped, reviewed
sets. For an ad-hoc env (build_env(path=…)), record the egress:
ledger line (see env-setup.md); that text is agent-written, so the
host re-validates and caps it. Either way it is a request the user
always sees, never an authority — it lands on the card as
(+K declared by this env), and a standing "always allow" grant never
skips a card that carries env-declared domains. Declare the exact
job-time hosts only (HYDRATE runs in the compute_provider kernel,
outside the fence), and you cannot set or widen the policy itself:
provider_params rejects egress_allowlist /
outbound_domain_allowlist outright and compute_details cannot reach
the column.
Skill-driven jobs and the fence
A skill is just commands inside a fenced sandbox: every host its
documented flow dials at job time either rides the merge above or dies
as a connection reset. So declare an env's job-time hosts (its
META["egress_domains"] / the ledger's egress: line) BEFORE the
first submit, and move big one-time fetches — weights, checkpoints,
sequence databases — out of the job entirely: bake them at image build
or stage them from the compute_provider kernel into a Volume, neither
of which is fenced (env-setup.md → "Tool recipes that survive the
egress fence" has the runnable recipes). Settings → Compute → modal →
Network Restrictions → Additional domains is the user-side fallback for
a fetch that truly must happen inside the job.
Knowing the policy, and reading a network failure
You never have to guess what a job is allowed to reach, because the host
tells you twice — three times when a compute_provider kernel is open:
compute_provider_config()['egress_mode'] there names the mode the
next sandbox will be created under, before any submit spends a card.
At submit time the reply carries an egress line and
submit_job prints it — [egress] sandbox egress at creation: allowlist (2 domains; mirror off; +0 from the env) — and the same string lives on
the handle as job.egress. That line describes the sandbox for its whole
life: the policy is captured when the sandbox is created, so what you see
there is what every command in that job runs under, no matter what the
Settings screen says five minutes later. Then, if the job's output ends up
carrying connection-level failures and the sandbox was fenced, the result
and the compute_done notification carry a second host-authored line
starting with [egress] that names the policy, its size, and the remedy.
Both lines are written by the host from the policy it actually enforced —
they are evidence you can repeat to the user verbatim, not something you
have to infer.
Knowing how a fence fails is what lets you attribute instead of flail.
Modal's allowlist is a TLS/443 SNI filter, so it never answers at the
HTTP layer; everything it refuses dies below that, and each spelling
tells you which rule you hit. These are the shapes we observed live, not
theory:
| what the output says | under an allowlist it means | under No network it means |
|---|
Connection reset by peer / ECONNRESET on an https:// URL | the host is not on the sandbox's list | (everything already fails earlier, at DNS) |
Network is unreachable / ENETUNREACH | the request was not TLS on port 443 — plain http://, a non-443 port, or a raw socket — and the filter refuses those even for an allowed domain | no route to anything |
Temporary failure in name resolution / EAI_AGAIN / Could not resolve host | rare under an allowlist (DNS itself is up) | the normal shape: there is no DNS and no network at all |
A 4xx/5xx, on the other hand, means the host was reachable and
answered; that is an application problem, not the policy.
The fix depends on the failure class, and the [egress] hint names the
suspect host (when the output names one) and the remedy. A host that
simply isn't listed: ask the user to add exactly that name under
Settings → Compute → modal → Network Restrictions → Additional domains
(or, for an env's documented commands, declare it in
META["egress_domains"]). A non-TLS / non-443 failure (apt-get, an
http:// mirror, a raw socket): no allowlist edit can fix it — use
HTTPS or do that install at image-build time, which is not fenced.
storage.googleapis.com during a lazy weights download: hydrate the
env's volume from the compute_provider kernel instead of opening the
world's most general bucket host. Under No network there is nothing to
add by design. Every policy is read only when a sandbox is created,
so resubmit on a fresh handle — retrying in (or warm-reusing) the
failed sandbox repeats the failure byte for byte.
Three moves look productive here and are not. Retrying the same command
in the same sandbox cannot succeed, because nothing inside a sandbox can
change its policy. Asking the user to switch to Unrestricted because
one domain is missing trades their whole egress posture for a one-line
allowlist edit — name the domain instead. And probing from inside the
job ("let me curl a few sites to test the network") only reproduces the
failure you already have; the [egress] line and job.egress already
tell you the policy without spending a container on it.
Two Modal-side caveats worth knowing before you debug "the network is
broken": Modal documents outbound_domain_allowlist as a Beta
feature, and it filters by TLS SNI, so it applies to TLS on port 443
only — once an allowlist is set, plain-HTTP and non-443 traffic from
the sandbox is refused regardless of the domain (we verified this live:
http://example.com is refused even with example.com on the list). A
tool that fetches over http:// will fail under any allowlist even if
its host is listed; the fix is the tool's HTTPS endpoint, not a wider
allowlist.
Common env gotchas
These are tool-level quirks, not architecture — things the bundled env files
already handle but that bite when you're writing an ad-hoc env or debugging
a job that should have worked.
Volume.from_name(...) returns a lazy handle — vol.object_id raises AttributeError: unhydrated until the volume is mounted into a Sandbox.create(volumes={...}). Don't try to read it; record the volume name (that's what provider_params.modal.volumes takes anyway).
- rdkit (deepchem, molfeat, aizynthfinder): needs
apt_install("libxrender1", "libxext6") or rdkit.Chem.Draw import fails on libXrender.so.1. The chemistry_gpu env includes these.
- dgl CUDA wheels live at
data.dgl.ai, not PyPI. envs/proteomics_rfd_diffdock_gpu.py shows the find_links= pattern.
- DestVI is
scvi.model.DestVI, not scvi.external.DestVI (moved in scvi-tools ≥1.0).
- Small model weights (esm2_t6_8M, bert-tiny) auto-download to
/root/.cache on first use — fine for smoke; for repeated jobs hydrate a Volume once.
- Volume +
TORCH_HOME path: torch.hub appends its own hub/ subdir. If you mount the volume at /weights and want torch to find checkpoints there, set TORCH_HOME=/weights (torch then reads /weights/hub/checkpoints/). Setting TORCH_HOME=/weights/hub makes it look at /weights/hub/hub/ and re-download.
- molfeat: use
from molfeat.trans.fp import FPVecTransformer; FPVecTransformer(kind="ecfp:4")(smiles) — MoleculeTransformer raises on default config.