Declare a typed job in .atl, implement its handler in your own service,
and run the submit–claim–complete loop.
1. Declare the job
In your repository’s .atl:
job ImportContacts in directory {
args {
account_id varchar(64) not null
import_strategy varchar(20) not null default "skip"
}
retries 3
timeout 30m
queue "contacts"
visible_to "directory"
}argsuses the entity field grammar (varchar, int, jsonb, arrays).visible_to "directory"restricts which caller may submit the job and claim it.
2. Apply
tide applyThe declaration is additive. The server enforces retries, timeout, and
queue at submit and claim time.
3. Generate, implement, and register the handler
tide generateAlongside the entity clients, tide generate writes client/<ns>/jobs.go
with a typed Args struct, a handler interface, and a Register<Job>
helper per declared job. Handlers run in your service’s binary:
type importContactsHandler struct{ crm *crm.Client }
func (h *importContactsHandler) Handle(ctx context.Context, args directory.ImportContactsArgs) error {
jobs.Checkpoint(ctx, 10, "fetching contacts")
// ... your import logic, reading args.AccountId ...
return nil
}
registry := jobs.NewRegistry()
directory.RegisterImportContacts(registry, &importContactsHandler{crm: crmClient})The helper decodes the row’s args JSON into the typed struct before
invoking Handle. (Registering under the raw id
"directory.ImportContacts" with jobs.HandlerFunc and your own
json.Unmarshal works the same way.)
A non-nil return retries the attempt up to the declared retries, then
dead-letters it. jobs.Checkpoint(ctx, pct, msg) reports progress and
extends the attempt’s lease; long-running handlers call it as they go.
4. Run a worker
The worker holds a gRPC session to your organisation’s server and receives dispatched work over it — your service needs no database access:
w := jobs.NewDispatchedWorker(conn, registry, "contacts", jobs.ServerConfig{
Logger: slog.Default(),
})
go w.Run(ctx)conn is your service’s authenticated *grpc.ClientConn to atlantis.
The queue must be one a declared job runs on ("default" when no job
names one) — an unknown queue is refused at session open. Run
reconnects on stream errors
with backoff; work in flight when a worker dies is re-dispatched to another
worker after its lease expires, so handlers must be idempotent.
5. Submit
tide job submit directory.ImportContacts \
--args='{"account_id":"acct_123","import_strategy":"replace"}'✔ submitted directory.ImportContacts as job 0198f2c1a4e07000
monitor with: tide job status 0198f2c1a4e07000Services submit through the same gRPC API (SubmitJob, requiring the
JOBS_WRITE capability), or atomically from a procedure:
procedure ConnectAccount for directory.Account {
input { account_id: varchar(64) }
steps {
update Account set status = "connected" where account_id = $account_id
enqueue directory.ImportContacts(account_id: $account_id, import_strategy: "replace")
}
}The enqueue shares the procedure’s transaction: if the procedure rolls back, the job is never enqueued. A brand-new procedure’s RPC becomes callable at the next server restart — see Custom queries and procedures.
Verify
job-id 0198f2c1a4e07000
job-name directory.ImportContacts
queue contacts
status complete
attempts 1 / 3
enqueued 2026-09-02T14:03:11ZThe status reaches complete, with any checkpoints your handler reported.
The console’s Workers page shows the connected worker session and its
queue.
Next steps
- Operate jobs and workflows — monitoring, the dead-letter queue, retries.
- Write a long-running handler — idempotency, leases, and resume-from-progress in depth.
- Jobs and workflows — the model.