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.
Scanned 9/4/2026
Install to Claude Code
npx -y skills add gabrielmoreira/agent-skills-mirror --skill remote-compute-modal --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Remote Compute Modal?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/gabrielmoreira-remote-compute-modal)More formats (shields.io, HTML) on the badges page.
---
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:
1. Container life starts at sandbox creation — staging the inputs counts
against it, so a big upload spends container time before the job runs.
2. The job runs. If it exceeds its own budget (the job timeout below), it
is TERMed there.
3. 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.
4. After any TERM there is a grace window for checkpoint-on-TERM handlers
to flush, then the process group is stopped.
5. 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:
```python
# repl tool
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.
```python
# compute_provider tool, provider='modal'
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', []))})
# → {'proteomics_gpu': {'packages': [...], 'gpu_default': 'A100-80GB', ...}}
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:
```python
# compute_provider tool — last line of the build cell
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):
```python
# repl tool — cell ① submits and RETURNS
import json
r = json.load(open('built_env.json'))
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': r['image'], # the im-… ref (bare name fallback brittle)
'env': 'proteomics_gpu', # bundled-env NAME → its shipped egress declaration applies
'gpu': 'A100', # timeout omitted → container gets the Settings default
'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, # job guard — this probe finishes in 5 min or it's hung
)
print('JOB_ID:', j.job_id) # ← cell ends here; kernel never blocks
```
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:
```python
# repl tool — cell ② after the compute_done notification
print(c.attach_job('<JOB_ID>').result()['stdout_tail']) # non-blocking — poller already harvested
c.close()
```
For a full job with inputs and harvested outputs:
```python
# repl tool — cell ① submits and RETURNS
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': r['image'], # 'im-…' from build_env(); bare-name fallback exists but is brittle
'env': 'proteomics_jax_gpu', # bundled-env NAME → its shipped egress declaration applies
'gpu': 'A100-40GB',
'cpu': 8,
'memory': 32768, # MiB — Modal's unit, not GiB
'volumes': {'/cache': 'af2-params'}, # mount path → Modal Volume name
'timeout': 3600, # container lifetime, seconds — optional; omitted → Settings default; ceiling: Modal's 24 h platform limit
}})
# → tier card → Sandbox.create() under the user's workspace → handle bound to sandbox_id
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, # job guard — optional; default = remaining container life − harvest margin
)
print('JOB_ID:', job.job_id) # ← cell ends here
```
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`):
```python
# repl tool — cell ② after the compute_done notification
r = c.attach_job('<JOB_ID>').result() # non-blocking read of compute_usage.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:
```python
# compute_provider tool — env setup (`modal` and `app` are pre-bound)
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) # → 'im-7f3a2e…' — read this string
```
```python
# repl tool — job
c = host.compute.create('byoc:modal', provider_params={'modal': {
'image': 'im-7f3a2e…', # the string, not the object
...
}})
```
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:
```python
# ── compute_provider tool ──
compute_provider_config()
# → {'provider': 'byoc:modal', 'environment': 'team-bio',
# 'app_name': 'claude-science-default', 'egress_mode': 'allowlist',
# 'config_hash': '93e1a4c20b7f'}
# …and after the user changes the Environment, Default app or Network
# Restrictions mode there, the same call:
# ComputeProviderConfigStale: compute provider config is out of date:
# this kernel was started with config 93e1a4c20b7f but the provider
# Settings now hash to 4be09f12aa31. Restart the compute_provider
# kernel (close it and open a new one) to pick up the new
# Environment/app/Network Restrictions configuration.
```
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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!