Jobs are deployed, named, async compute. Submit returns immediately with a run guid; the work runs on a sandbox container (CPU) or on Modal (GPU). Use jobs for anything that doesn't fit in a function (functions are 30 s / 128 MB; jobs go up to 60 min and 8 GB CPU / a full GPU).
Common shapes:
- Audio / video pipelines (ffmpeg, sox, whisper)
- Image-to-video - animate/"bring to life" a user's uploaded photo (LTX-Video, Stable Video Diffusion). This is the GPU-job path for image-conditioned motion; the app-video service is text-to-prompt only and can't preserve a specific image.
- ML inference that needs a GPU (Flux, LTX-Video, protein folding, transcription)
- Fine-tuning / training runs
- Batch processing (OCR, document conversion, embedding millions of items)
- Long ETL that exceeds function limits
Declaring a job
Add a jobs: phase to gipity.yaml:
version: 1
deploy:
phases:
- name: my-jobs
type: jobs
job_definitions:
- name: transcribe
handler: jobs/transcribe/main.py
runtime: python-3.11
compute: gpu-small # L4 GPU
timeout_ms: 300000 # 5 minutes
deps: jobs/transcribe/requirements.txt
retries: 1
description: "Whisper transcription of an audio file"
Then drop the handler at jobs/transcribe/main.py. Deploy with gipity deploy dev.
Inline annotations (optional)
Instead of declaring everything in gipity.yaml, you can write # @gipity:job at the top of the handler:
# @gipity:job runtime=python-3.11 compute=gpu-small timeout=5m
import whisper
# ...
YAML wins on conflict; annotations seed defaults only.
Compute classes
Each class maps to a substrate + resource shape. Billed per second based on actual wall time of the run (deps install included).
| Class | Substrate | RAM | Notes |
|---|---|---|---|
cpu-small |
Docker sandbox host | 1 GB | Default. Fine for ffmpeg / pandoc / pip-installed Python work |
cpu-large |
Docker sandbox host | 8 GB | Heavy CPU work - large encodes, document batches, ETL |
gpu-small |
Modal L4 (24 GB VRAM) | per Modal | Most inference; the cheapest GPU |
gpu-medium |
Modal A10G (24 GB VRAM) | per Modal | Slightly faster than L4 for some workloads |
gpu-large |
Modal A100-40GB | per Modal | Large model training / fine-tuning |
gpu-huge |
Modal H100 | per Modal | Heaviest training, big batched inference |
timeout_ms caps the run inside the container (max 60 min). Modal kills the underlying call if our soft deadline is exceeded.
Runtimes & the fat image
Pick a runtime with runtime: (or the # @gipity:job runtime=... annotation). Valid values: python-3.11, node-24 (Node 24; node-20 is still accepted as a back-compat alias for the same runtime), and bash. Omit it and it's inferred from the handler extension (.py->python, .js->node-24, .sh->bash).
Node is CPU-only. The GPU image ships Python and Bash but no Node, so a node-* runtime must pair with a cpu-* compute class. Declaring runtime: node-24 (or node-20) with a gpu-* compute is rejected at deploy and at submit with a clear error - for GPU work, use python-3.11.
GPU runs execute in a Modal container preloaded with:
- System: ffmpeg, sox (incl. mp3/ogg/flac via libsox-fmt-all), imagemagick (PDF coder enabled), poppler-utils, tesseract, build-essential, git, git-lfs, libsndfile, libgl
- Python: torch, transformers, accelerate, diffusers (+ imageio-ffmpeg, so
diffusers.utils.export_to_videoworks for image-to-video jobs), peft, datasets, safetensors, sentencepiece, tokenizers, librosa, soundfile, openai-whisper, demucs (split via the Python API + soundfile, not the CLI - see gotchas), opencv-python-headless, scikit-image, numpy, scipy, pandas, pillow, httpx, pydantic, requests, boto3, huggingface_hub - Plus torchaudio (explicitly baked into the image) - so
torchaudio.pipelines.MMS_FAforced alignment works without pip install
If your job only needs the above, omit deps: entirely - the container starts faster.
CPU runs use the same image as gipity sandbox run - so its toolkit is exactly the sandbox-tools skill's list (ffmpeg, ImageMagick, poppler's pdftotext/pdfinfo/pdftoppm, tesseract, LibreOffice, pandas/numpy/scipy, etc.). Read sandbox-tools to see what's already there instead of probing for a binary. Note it's the CPU image only; the GPU (Modal) image is the separate list above.
Persistent cache
Both substrates mount a project-shared volume at /cache. HF/torch model weights cache there by default (env: HF_HOME=/cache/hf, TORCH_HOME=/cache/torch). First job downloads the weights; subsequent jobs are instant.
Use /cache/<your-key>/ for anything you want persisted across runs (intermediate artifacts, downloaded datasets). Don't put per-run output there - use app-files to upload final results.
Handler contract
The handler is invoked with these env vars set:
| Var | Use |
|---|---|
GIPITY_RUN_GUID |
Pass to log/progress callbacks |
GIPITY_RUN_INPUT |
JSON of the input arg from submit |
GIPITY_APP_GUID |
Project short_guid |
GIPITY_APP_TOKEN |
JWT for calling /api/{appGuid}/services/* |
GIPITY_PROGRESS_URL |
POST progress updates here |
GIPITY_OUTPUT_URL |
POST your structured result here (or use ctx.set_output) |
GIPITY_PROGRESS_TOKEN |
Auth token for progress + output + log endpoints |
GIPITY_API_BASE |
Base URL (e.g. https://a.gipity.ai) |
Identical on CPU and GPU. Every var above - plus the gipity_ctx helper (gipity_ctx.py / gipity_ctx.js, see below) - is injected the same way on the Docker (CPU) and Modal (GPU) substrates, so set_output, progress, and the output channel work on a gpu-* job exactly as on a cpu-* one; don't gate any of this on compute class.
Job output contract
Set your result with ctx.set_output(...) - don't print it to stdout. stdout and stderr are captured as logs (streamed live via gipity job logs <runGuid>, and the final stdout is stored on output only as a fallback). The clean way to return a structured result is the dedicated output channel:
from gipity_ctx import ctx
ctx.set_output({"stems": stem_urls}) # this becomes the run's output
const { ctx } = require('./gipity_ctx');
await ctx.set_output({ stems: stemUrls });
set_output blocks until the platform has stored the result and throws if it can't, so a lost result fails loudly instead of vanishing. Whatever you pass is exactly what an on_complete hook receives as output - independent of stdout, so you can print()/console.log diagnostics and let libraries log freely without corrupting it. Call it once; the last call wins.
256 KB cap. The serialized output must be ≤256 KB; a larger payload is rejected (HTTP 413 RESULT_TOO_LARGE) and set_output throws. For a big result (a rendered file, large PDB/CSV/JSON), upload it with ctx.upload_file(path) (see "uploads the files it produces" below) and set_output the returned URL, or compress to fit (e.g. gzip+base64). Logs (stdout/stderr) share the same 256 KB cap; the live log stream is unbounded.
Before / after - the trap set_output removes:
# ❌ Before: result on stdout. One stray log line (yours or a library's) and the
# platform can't parse stdout as JSON, so output becomes {stdout: "...logs..."}
# and your on_complete hook sees output.stems as undefined.
print("loading model…") # pollutes stdout
print(json.dumps({"stems": urls})) # intended result
# ✅ After: result on its own channel; logs go anywhere.
print("loading model…") # just a log - harmless
ctx.set_output({"stems": urls}) # the result
If a job never calls set_output, the platform still falls back to parsing stdout - the whole stream as one JSON value, or a single JSON object/array at the very end - and otherwise stores the raw stream as {stdout: "..."}. Prefer set_output; the fallback is best-effort.
stderr is captured but not streamed live (yet); it surfaces in error_message on failure.
Progress callbacks
POST to $GIPITY_PROGRESS_URL with {"pct": 0.5, "message": "halfway"} - pct is 0-1 (not 0-100); a value >1 is rejected with HTTP 400. Updates progress_pct and progress_message on the run row. Best-effort: log any failure to stderr, never stdout (stdout is the result).
gipity_ctx is the blessed pattern - prefer it over hand-rolling these POSTs. The helper (gipity_ctx.py for python, gipity_ctx.js for node) is injected alongside the handler on both substrates, so use it on GPU jobs too:
from gipity_ctx import ctx
ctx.progress(0.5, "halfway")
url = ctx.upload_file("/work/result.pdb", public=True)["url"] # return a produced file
ctx.set_output({"pdb_url": url})
The helper's surface: ctx.input, ctx.progress(), ctx.set_output(), ctx.upload_file(), and ctx.fetch(path) (an authenticated call to your app's own /api/{appGuid}/<path> - services, records, files, and your own deployed functions via fn/<name>). All authenticate with the job's app token for you, so you never assemble a raw URL or reason about job networking - ctx.fetch("fn/save-page", method="POST", body={...}) reaches a public deployed function from inside a running job.
(An older handler that hand-rolls the raw $GIPITY_PROGRESS_URL / $GIPITY_OUTPUT_URL POSTs predates SDK injection on Modal - not the pattern to copy.)
CLI surface
gipity job list # all jobs in current project
gipity job submit <name> [body] # returns a run guid
gipity job wait <runGuid> [--timeout 90] # block until the run finishes; the way to await a job
gipity job status <runGuid> # one instant snapshot (no blocking)
gipity job runs <name> [--limit 20] # recent runs of one job
gipity job logs <runGuid> [--follow] # SSE stream by default; --no-follow = instant snapshot incl. recent stdout/stderr tail
gipity job cancel <runGuid> # queued or running -> cancelled
gipity job run-local <name> # Docker-on-your-machine for dev iteration
Submit accepts --data '<json>' for input (or pass a JSON arg directly) and --idempotency-key <key> for replay.
Await a run with gipity job wait - don't hand-roll a status-poll loop. It blocks until the run reaches a terminal state and prints its output, or, if --timeout (seconds, default 90) elapses first, prints current progress and exits non-zero. Exit codes are distinct so a script can branch: 0 = success, 1 = the run failed/cancelled, 2 = still running at timeout (re-run job wait to keep waiting). So one call can never hang your shell - it either finishes or tells you to poll again - and you never write a sleep+job status loop or block on job logs --follow. Use job status only when you want a single instant snapshot without waiting.
Agent tools
The same six verbs are exposed as agent tools, with the same parameter shapes:
job_list(VIEWER) - list jobs in current projectjob_submit(EDITOR) - submit a job; returns{run_guid, status, replayed}job_status(VIEWER) - short status snapshotjob_runs(VIEWER) - recent runs of a named jobjob_logs(VIEWER) - captured stdout / stderr / output, truncated at 5 KBjob_cancel(EDITOR) - flip a queued/running run to cancelled
Agents and the CLI hit the same /projects/{guid}/jobs/... endpoints; their behavior matches.
REST endpoints (project-scoped, JWT auth)
| Method | Path | Purpose |
|---|---|---|
GET |
/projects/{guid}/jobs |
list jobs |
POST |
/projects/{guid}/jobs/{name}/submit |
submit (returns runGuid) |
GET |
/projects/{guid}/jobs/runs/{runGuid} |
one run's status |
GET |
/projects/{guid}/jobs/{name}/runs |
list runs of one job |
GET |
/projects/{guid}/jobs/runs/{runGuid}/logs/stream |
SSE log stream |
POST |
/projects/{guid}/jobs/runs/{runGuid}/cancel |
best-effort cancel |
Submit body: { "input": {...}, "idempotency_key": "..." }.
In-function jobs API
A deployed function submits and polls jobs through jobs - a capability on the handler's second argument, a peer of db/storage (NOT ctx.jobs - ctx only carries body/query/headers/method/auth). Like db, it needs no services: declaration. No HTTP layer, same concurrency caps / GPU gating / idempotency replay as the CLI. All three throw on error:
export default async function startFold(ctx, { jobs }) {
const { run_guid, status, replayed } = await jobs.submit('fold', { seq }, { idempotency_key: 'opt' });
const s = await jobs.status(run_guid);
// s -> { run_guid, status, progress_pct, progress_message, attempt, started_at, completed_at, duration_ms, error, output }
// output is populated once status === 'success' - the value the handler passed to set_output.
await jobs.cancel(run_guid); // -> { status }
return { run_guid };
}
A function returns in ≤30 s - don't poll a long job to completion inside it. Submit and return run_guid, or set on_complete (below) to fire a function when the run finishes.
State machine
queued → running → success | failed | cancelled
Status writes are atomic - only one transition wins. Retries (retries: N in the YAML) re-claim a failed row back to running for the next attempt, with exponential backoff. Deps-install failures are terminal - no retries (the user's setup is wrong; retrying won't help).
Failed and cancelled runs are billed for the time they actually ran (Modal / Docker charged us). Successful runs are billed for the same.
Billing
Per-second metering by compute class. Underlying USD/sec rates track Modal's published prices; 100 % platform margin applied. Both lines visible on the credits Pricing tab (Jobs category).
A 30-second L4 (gpu-small) job at 100 % margin ≈ $0.013 in credits (Modal's $0.80 / hr × 30 s × 2).
Auto-scaling: parallel jobs run in parallel
Submit N jobs concurrently and they execute concurrently. Modal's worker fleet spins up a fresh container per submission within seconds (cached image), runs your handler, and tears down - no per-job queue, each submission gets its own container.
The only ceiling is the project-level cap of 10 in-flight jobs (queued + running). If you submit an 11th while 10 are already in flight you get RATE_LIMITED (HTTP 429); wait for some to finish. This cap is per project, not per compute class - 5 L4s and 5 H100s in flight = cap reached.
Practical implications:
- Batch workloads: submit 10 jobs in a tight loop and watch them all start. Modal handles the fan-out at the substrate layer.
- Warm-pool freebie: containers are kept alive ~60 s after a job finishes, so a second submission within that window starts in <5 s instead of paying the ~10-30 s cold-start.
- Image is cached fleet-wide: the 15GB fat image is pulled to each Modal worker once. Steady-state cold starts are bounded by Python interpreter + your handler's imports.
- What we don't expose yet:
fn.map()fan-out (one submission → N parallel runs),min_containers(keep N warm at idle cost), per-containerconcurrent_inputsfor IO-bound serving. All are Modal-native and would land in future PRs if you hit the use case.
Limits
- Max concurrent (queued + running) jobs per project: 10
- Max
timeout_ms: 60 min (3,600,000) - Max retries: 10
- Default 5-min deps install timeout
- Result stdout / stderr capped at 256 KB (truncation marker preserved). Live log stream is unbounded - store large outputs via
app-files.
Common patterns
Reads an input file + uploads the files it produces
A job returns files with ctx.upload_file(path) - one call that stores the file in the app and hands back its {guid, url, variants}. That's the answer to "how does a GPU job get its output files back": don't hand-roll the upload endpoints, and don't try to cram bytes into set_output (that channel is ≤256 KB of JSON). Upload each file, then set_output the urls.
Feeding a job a real input file when testing: gipity upload. gipity upload <file> uploads a local file and prints a durable, worker-reachable URL the instant it lands - no gipity deploy needed (a file pushed into the project tree with gipity push has no public URL until you deploy; this does). Pass that URL straight into the job input:
gipity upload song.mp3
gipity job submit split-stems --data '{"audio_url":"<printed url>"}'
By default the file is PUBLIC (a plain media.gipity.ai CDN url that resolves from anywhere, so a cloud worker can always fetch it); --private mints a token-signed serve url instead, which workers can still fetch (see below).
from gipity_ctx import ctx
# Download the input. The URL must be reachable from the cloud worker: pass what
# storage.fileUrl(guid) returns for the uploaded file. This is reachable whether the
# file is PUBLIC (a media.gipity.ai CDN url) or PRIVATE (a tokenized a.gipity.ai serve
# url) - a private file's serve url is a public https endpoint gated by a capability
# token, so the worker fetches it fine. DON'T flip a user's upload to public just to
# feed a job; keep private uploads private. A deployed https://dev.gipity.ai/… file
# also works. Only an app-internal/localhost url won't resolve.
import urllib.request
urllib.request.urlretrieve(ctx.input["audio_url"], "/work/song.mp3")
# ...process with ffmpeg / whisper / demucs, writing outputs under /work ...
stems = {name: ctx.upload_file(f"/work/{name}.wav", public=True, content_type="audio/wav")["url"]
for name in ("vocals", "drums", "bass", "other")}
ctx.set_output({"stems": stems}) # the ≤256 KB JSON result: just the urls
Node: await ctx.upload_file('/work/out.mp4', { public: true }) returns the same { guid, url, ... }.
Fan-out via on_complete
- name: align
handler: jobs/align/main.py
compute: gpu-small
on_complete: render-preview # name of a deployed function
When the align job hits a terminal state the platform fires the named function with { run_guid, status, output, error }. The render function can then submit a new render job.
Job persists structured results by calling a function
The canonical ingestion pipeline: a function submits a cpu-* job, the job does the heavy work, and posts results back to a deployed function that writes them to the database. Use ctx.fetch("fn/<name>", ...) - it targets your own function with the app token attached, so you never build the raw URL or worry about whether the container can reach the API (it can; both substrates share the host network). This works to stream partial results during the run, not just at the end like on_complete.
from gipity_ctx import ctx
import urllib.request, subprocess
urllib.request.urlretrieve(ctx.input["pdf_url"], "/work/doc.pdf")
n_pages = int(ctx.input["pages"])
for page in range(1, n_pages + 1):
text = subprocess.run(
["pdftotext", "-f", str(page), "-l", str(page), "/work/doc.pdf", "-"],
capture_output=True, text=True).stdout
# POST each page straight into a deployed function that inserts a DB row.
ctx.fetch("fn/save-page", method="POST",
body={"doc_id": ctx.input["doc_id"], "page": page, "text": text})
ctx.progress(page / n_pages, f"page {page}/{n_pages}")
ctx.set_output({"pages_indexed": n_pages})
The save-page function is an ordinary deployed function (auth_level: public so the job's app token reaches it) that does the insert. No webhook URL, no X-App-Token plumbing - ctx.fetch is the whole story.
Testing & gotchas
Testing a job pipeline (submits are suppressed in a test run, so you drive the trigger fn and on_complete fn directly - plus the one-time real GPU smoke from the CLI) and the common gotchas (the Demucs 4-stem recipe, deps that conflict with the fat image, bash set -e, and the output channel) live in the jobs-reference skill.
See also
- jobs-reference - testing a job pipeline and the common gotchas
- deploy - how the jobs phase fits into the rest of the deploy pipeline
- app-development - functions vs jobs, when to use which
- app-files - uploading artifacts the job produces
- app-debugging -
page inspect, function logs, run logs