| 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.