What a worker is
A worker is structurally a job: container starts, does its thing, writes outputs.json, exits. The difference is who triggers it. For jobs, a human (or some external caller) hits Run; for workers, an in-process cron scheduler on the platform fires it on the cadence you configured.
every minute the scheduler ticks: ┌──────────────────────────────────────────┐ │ for w in workers where enabled = true │ │ and nextRunAt <= now: │ │ start a run with w.inputs │ │ set lastRunAt = now │ │ set nextRunAt = next(w.schedule) │ └──────────────────────────────────────────┘
The scheduler lives in src/server/scheduler.ts and uses croner for cron parsing — standard 5-field syntax, plus seconds if you really want them.
When to pick worker
- You want the block to run without anyone clicking anything.
- The cadence is regular: every X minutes/hours/days.
- Common shapes: daily standup digest, hourly news pull, nightly DB sync, periodic LLM-backed monitoring ("does this dashboard look weird?").
- Each tick is independent and short-ish. (For very long workflows, schedule a thin worker that kicks off a longer job.)
When NOT to pick worker
- You only need to run on user click — that's a job.
- You need a long-running process that's always up — use a service instead. Workers are short-lived per tick; if you need warm state, you'd be paying cold-start over and over.
- The thing you want to schedule needs irregular triggers (webhooks, queue messages). Use a service with a POST endpoint and have your event source call it.
What you'll build
A worker called e2e-worker that, every two minutes, asks the model for a 3-line haiku about a topic stored in its inputs. Tiny on purpose so the full loop (deploy → register schedule → watch the scheduler fire it → disable) is easy to verify.
By the end you'll have seen this in your run list:
succeeded e2e-worker cmoogq152001h4a1de10sddvd succeeded e2e-worker cmoogo4ib000s4a1dcck1uwox
…with the second run fired by the scheduler, not by you.
manifest.yaml
name: e2e-worker
version: 0.1.0
type: worker
description: End-to-end test worker. Writes a one-line haiku each tick.
category: misc
inputs:
topic:
type: string
required: false
default: "Hello from a scheduled worker"
description: Topic the worker writes a haiku about.
outputs:
haiku:
type: string
description: A 3-line haiku about the topic.
runtime:
build: dockerfile
entrypoint: python main.py
outputs_dir: /out
env:
MODEL: qwen3-235b
resources:
cpu: 1
memory_mb: 512
timeout_seconds: 120
network: deny
pricing:
type: per_run
base_price_cents: 0
rate_cents_per_minute: 0
inference_pass_through: true
inference_markup_pct: 0Differences from the job manifest in the previous guide:
- type: worker — that's it. The platform uses the same orchestrator for the actual run; the type just enables the "Schedule" UI on the block page, and tells the scheduler to look at this block.
- All inputs should have
defaultor be optional. The schedule is registered once with a fixed input bag — there's nobody to type new values at 03:00. Required-without-default + scheduled = the run will fail.
main.py
Identical shape to the job. The container has no idea it's being scheduled — env vars in, JSON out, exit.
"""Worker entrypoint — same shape as a job, but the manifest type is
'worker' so the platform can invoke it on a schedule.
"""
import json
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["OPENAI_BASE_URL"],
api_key=os.environ["OPENAI_API_KEY"],
)
MODEL = os.environ.get("MODEL", "qwen3-235b")
OUT_DIR = os.environ.get("GONKA_OUTPUTS_DIR", "/out")
topic = os.environ.get("INPUT_TOPIC", "Hello from a scheduled worker").strip()
print(f"==> Worker tick. Topic: {topic}")
resp = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "system",
"content": (
"Reply with a single 3-line haiku. Plain text. No"
" preamble, no quotes, no markdown."
),
},
{"role": "user", "content": topic},
],
temperature=0.7,
)
haiku = (resp.choices[0].message.content or "").strip()
print(haiku)
os.makedirs(OUT_DIR, exist_ok=True)
with open(os.path.join(OUT_DIR, "outputs.json"), "w") as f:
json.dump({"haiku": haiku}, f, indent=2)
print("==> done")Same Dockerfile as the job guide too — copy that one verbatim.
Deploy the block
$ gonkablocks deploy packaging /private/tmp/gbk-e2e/e2e-worker → e2e-worker@0.1.0… uploading 2 files… building… • ready publishing… ✓ published. View at https://blocks.gonka.gg/blocks/admin/e2e-worker
Note that the block builds and is publishable on its own — at this point no schedule exists and the block won't fire automatically. You can also run it manually right now with gonkablocks run admin/e2e-worker to verify the code works before you let cron loose on it.
Register a schedule
Two ways to do this. Either is fine.
Option A: the block's page
Open the block in your browser, click Schedule, fill in the cron expression, the inputs, and a per-run spend cap, click Enable. The platform validates the cron immediately — bad expressions fail at form submit, not at midnight.
Option B: REST API
# 1. resolve the block id from author/slug
curl -H "Authorization: Bearer $GONKA_API_KEY" \
"https://blocks.gonka.gg/api/blocks/resolve?author=admin&slug=e2e-worker"
# {"blockId":"cmoogniua000o4a1drn0dtc43"}
# 2. create a Worker row
curl -X POST https://blocks.gonka.gg/api/workers \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"blockId": "cmoogniua000o4a1drn0dtc43",
"name": "e2e haiku tick",
"schedule": "*/2 * * * *",
"inputs": {"topic": "morning espresso"},
"spendCapCents": 20
}'
# {"worker":{"id":"cmoogpgol001f4a1df6e1xe8w", ..., "nextRunAt":"2026-05-02T14:54:00.000Z"}}The response includes nextRunAt — the exact UTC moment the scheduler will fire it. Look at that field after your first POST to confirm cron parsed the way you expected.
Auth note: the workers endpoint accepts either a session cookie or a gk-live-… bearer token, scoped to the user who owns the block. You can't schedule someone else's public block as a worker — fork it first.
Cron syntax cheatsheet
Standard 5-field UNIX cron, in UTC. From left to right: minute, hour, day-of-month, month, day-of-week.
| Want it to run… | Expression |
|---|---|
| every 2 minutes | */2 * * * * |
| every 15 minutes | */15 * * * * |
| every hour, on the hour | 0 * * * * |
| every day at 09:00 UTC | 0 9 * * * |
| weekdays at 09:00 UTC | 0 9 * * 1-5 |
| every Monday at midnight UTC | 0 0 * * 1 |
| first of the month, 04:00 UTC | 0 4 1 * * |
All times are UTC. The scheduler doesn't respect the viewer's timezone — convert before you schedule. (crontab.guru is great for sanity-checking expressions.)
Watch it fire
The scheduler ticks every 60s. Wait one or two ticks past your nextRunAt and check:
curl -H "Authorization: Bearer $GONKA_API_KEY" \
https://blocks.gonka.gg/api/workers
# {"workers":[{
# "name":"e2e haiku tick",
# "schedule":"*/2 * * * *",
# "lastRunAt":"2026-05-02T14:54:22.775Z",
# "nextRunAt":"2026-05-02T14:56:00.000Z",
# ...
# }]}Once lastRunAt is populated, a fresh run will appear in your run list:
$ gonkablocks runs succeeded e2e-worker cmoogq152001h4a1de10sddvd <-- fired by scheduler succeeded e2e-worker cmoogo4ib000s4a1dcck1uwox <-- earlier manual run
The scheduled run is just a normal Run row — it has live logs, structured outputs, and a viewer URL like any other. You can also browse them filtered by worker on the block's schedule page.
Manual runs alongside
A worker block is still a regular block — you can hit gonkablocks run on it any time, with different inputs, even while the schedule is active. Manual runs don't affect nextRunAt; they're a separate flow.
That's very useful for: testing a new prompt before it becomes the next scheduled tick, replaying a failure with diagnostic input, or letting a human override the cadence once.
Spend caps
spendCapCents is the most important field on a worker. Default is 50 ¢; tune it per the workload.
- The cap is per run, not per day. A misbehaving worker that ticks every minute can still rack up bills if you set a high cap.
- When inference cost crosses the cap mid-run, the proxy starts returning errors and your code can either retry (waste!) or detect and exit cleanly. Prefer the latter:
from openai import APIStatusError
try:
resp = client.chat.completions.create(...)
except APIStatusError as e:
if e.status_code == 402:
print("==> hit per-run spend cap, exiting cleanly")
sys.exit(0)
raiseFor a worker that ticks every 2 minutes, 20 ¢/run × 720 runs/day = $144 worst case. Set caps with that math in mind.
Pause, edit, delete
From the schedule page or the API:
# pause (keeps the row, just sets enabled=false)
curl -X PATCH https://blocks.gonka.gg/api/workers/<workerId> \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
# update schedule or inputs
curl -X PATCH https://blocks.gonka.gg/api/workers/<workerId> \
-H "Authorization: Bearer $GONKA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"schedule": "*/15 * * * *", "inputs": {"topic": "tea"}}'
# delete
curl -X DELETE https://blocks.gonka.gg/api/workers/<workerId> \
-H "Authorization: Bearer $GONKA_API_KEY"Disabling preserves the run history. Deleting a worker does not delete its past runs — those stay tied to the block.
Pitfalls
- Cron in local time. All schedules are UTC. A worker scheduled at
9 9 * * *is firing at 09:00 UTC, not your local 09:00. If your users care about local time, do the conversion in your head once when you set up the worker. - Required input with no default. The scheduler can't prompt for missing inputs. If your manifest declares
required: truewith nodefault, the scheduled run will fail validation. Either default everything, or store the value in the worker'sinputsfield at schedule-creation time. - Two ticks at the same time. If the scheduler is delayed (rare, but happens during deploys), two ticks may fire back-to-back to catch up. Make your worker idempotent — re-running shouldn't corrupt the downstream state.
- Drift on slow runs. If a tick is supposed to fire every 2 min but takes 3 min, the next fire is computed from the cron expression, not from the previous finish. Slow workers will overlap unless you cap their runtime.
- The scheduler runs on the platform, not your machine. Closing your laptop doesn't pause anything. To stop a worker, disable it via the API or UI.
Next steps
- Want the worker to write to a database / S3 / Notion? Whitelist hosts in
resources.networkand use atype: secretinput for the credentials. Full breakdown in the secrets section. - Want a per-user chat instead of a scheduled tick? Session blocks.
- Want an HTTP endpoint your other systems can hit? Service blocks.