Asynchronous Jobs
Coordinate long-running work across clients: start a job, resolve it with a result, and await the outcome from anywhere.
Why Use Jobs
Some work outlives a single request: generating a large report, processing an uploaded dataset, or running a long AI task. The code performing the work and the code waiting for the result often live in different places: a backend function does the processing while a browser tab waits to show the outcome.
Without jobs, you would poll a database record for status changes or wire up custom notification channels. With jobs, the producer starts a job under a private ID and resolves it when done, and any client that knows the ID awaits the result:
// Producer: start the job, do the work, resolve it
const jobId = crypto.randomUUID();
await squid.job().startJob(jobId);
const result = await doLongRunningWork();
await squid.job().completeJob(jobId, result);
// Consumer: any client that knows the job ID
const result = await squid.job().awaitJob(jobId);
The jobs API is currently available in the TypeScript Client SDK. The Python client does not include a jobs API.
Overview
A job is a lightweight record tracking one unit of asynchronous work. It carries a status (in_progress, completed, or failed) and, once resolved, a result or an error message. Jobs are client-owned: the caller generates the job ID, starts the job, and is responsible for resolving it. Squid keeps the job alive while its owner is connected and delivers the outcome to every waiter.
Squid also uses the same job machinery internally for long-running executions, such as CLI agent model asks; you can track those with the same getJob() and awaitJob() calls.
When to use jobs
| Use Case | Recommendation |
|---|---|
| A backend function performs long work while a client waits | Start a job in the backend, return the ID, and awaitJob() on the client |
| Track the progress of a long-running AI agent ask | Pass a jobId to ask() or use askAsync(), then await the job |
| Check the current status of ongoing work | getJob() |
| Keep UI in sync with database changes | Use real-time queries instead |
| Run a function on a schedule | Use a scheduler instead |
How it works
- The producer generates a unique, private job ID and calls
startJob(jobId) - Squid marks the job
in_progress. The owning client keeps the job alive automatically; if the owner disconnects, Squid fails the job so waiters are not left hanging - Consumers call
awaitJob(jobId)to wait for the outcome, orgetJob(jobId)to poll the current status - The producer resolves the job with
completeJob(jobId, result)orfailJob(jobId, error) - Every waiter receives the result (or the error), including waiters that subscribe after the job already finished
Quick Start
The most common pattern: a backend executable kicks off long-running work and returns a job ID immediately, and the client awaits the job.
Step 1: Start and resolve the job in the backend
import { executable, SquidService } from '@squidcloud/backend';
export class ReportService extends SquidService {
@executable()
async startReportGeneration(reportType: string): Promise<string> {
this.assertIsAuthenticated();
const jobId = crypto.randomUUID();
await this.squid.job().startJob(jobId);
// Run the work out-of-band; the executable returns immediately.
this.generateReport(jobId, reportType);
return jobId;
}
private async generateReport(jobId: string, reportType: string): Promise<void> {
try {
const report = await this.buildReport(reportType); // Long-running work
await this.squid.job().completeJob(jobId, report);
} catch (error) {
await this.squid.job().failJob(jobId, String(error));
}
}
}
Step 2: Await the job from the client
const jobId = await squid.executeFunction('startReportGeneration', 'quarterly');
// Resolves with the report once the backend completes the job,
// or throws if the backend fails it.
const report = await squid.job().awaitJob(jobId);
Core Concepts
Job IDs are private capabilities
The job ID is the only credential attached to a job: anyone who knows a job's ID can read its status and result. Treat IDs like secrets:
- Generate unguessable IDs (for example with
crypto.randomUUID()) - Never reuse a job ID
- Share the ID only with the clients that should see the result
Starting and resolving jobs requires an API key, typically your backend (where this.squid is already authenticated) or a trusted server-side client. Reading and awaiting jobs requires no API key, only the ID.
Method reference
Access the jobs API with squid.job():
| Method | Returns | API key required | Description |
|---|---|---|---|
startJob(jobId) | Promise<void> | Yes | Starts a job owned by this client. Idempotent for an existing job. |
completeJob(jobId, result) | Promise<void> | Yes | Resolves a job this client owns successfully with its result |
failJob(jobId, error) | Promise<void> | Yes | Resolves a job this client owns as failed with an error message |
getJob<T>(jobId) | Promise<AsyncJob<T> | undefined> | No | Returns the job's current status and, if completed, its result |
awaitJob<T>(jobId) | Promise<T> | No | Waits until the job finishes; resolves with the result or throws |
The AsyncJob type
getJob() returns an AsyncJob:
| Field | Type | Description |
|---|---|---|
id | string | The job ID |
createdAt | Date | When the job was started |
updatedAt | Date | Last status change |
status | 'in_progress' | 'completed' | 'failed' | Current state of the job |
result | T (optional) | The result, present when status is 'completed' |
error | string (optional) | The error message, present when status is 'failed' |
Ownership and keep-alive
The client that calls startJob() owns the job and is the only client allowed to resolve it; resolving another client's job fails with a NOT_JOB_OWNER error. While the owner stays connected, the SDK keeps its jobs alive automatically in the background.
If the owner disconnects or crashes before resolving, Squid fails the job with Job failed due to client failover, and a gracefully shut-down client fails its unresolved jobs with Client shut down. Either way, waiters are released with an error rather than hanging forever.
Once a job reaches a terminal state, it stays there; a late completeJob() or failJob() on an already-resolved job is ignored.
Waiting semantics
awaitJob()resolves for every waiter; multiple clients can await the same job and all receive the result- Subscribing after the job already finished still resolves immediately with the terminal outcome
- Waiters can subscribe before the job starts:
awaitJob()on an ID that has no job yet simply waits, and resolves once the job is started and completed. The flip side is that awaiting a mistyped or expired job ID waits indefinitely; see Error Handling
Retention
Completed and failed jobs are retained for about one hour after they resolve, then deleted. Retrieve results promptly, and persist anything you need long-term to a database or storage. In-progress jobs are never swept; they remain until resolved or failed over.
Using Jobs with AI Agents
Agent asks accept an optional job ID, which is especially useful for long-running requests. Use askAsync() to fire the request without blocking, then track it through the jobs API:
const jobId = crypto.randomUUID();
// Returns immediately; the ask runs as a job.
await squid.ai().agent('research-assistant').askAsync('Analyze the quarterly report', jobId);
// Await the answer whenever (and wherever) you need it.
// An askAsync job resolves with the agent's answer as a plain string.
const answer = await squid.job().awaitJob<string>(jobId);
You can also pass a jobId as the final argument to ask() or chat() to make a blocking call trackable from other clients; jobs started through ask() resolve with an AiAskResponse object instead of a plain string.
Asks that use CLI agent models (such as Claude Code or Codex) always run as long-running jobs internally, since they can take many minutes to complete; providing a jobId gives you a handle to await or poll instead of holding a single request open.
Error Handling
| Error | Cause | Solution |
|---|---|---|
UNAUTHORIZED on startJob/completeJob/failJob | The client is not authenticated with an API key | Start and resolve jobs from the backend or a client initialized with an API key |
NOT_JOB_OWNER | A client tried to resolve a job started by another client | Resolve jobs only from the client that started them |
awaitJob rejects with the job's error | The producer called failJob, or the owner disconnected | Handle the rejection; inspect the error message for the cause |
awaitJob never settles | The job ID is wrong, or the job expired (~1 hour after resolving) before the waiter subscribed | Verify the job ID, await results promptly, and wrap the wait in your own timeout |
Job fails with Failed to store job result | The result exceeded the storage size limit | Store large payloads in storage and complete the job with a reference |
Best Practices
- Generate unguessable job IDs. The ID is the only access control on a job; use
crypto.randomUUID(), never sequential or predictable values. - Always resolve your jobs. Wrap the work in
try/catchand callfailJob()on error so waiters get a meaningful failure instead of a generic failover error. - Start and resolve from the backend. Backend code is already authenticated with an API key and stays connected for the duration of the work.
- Keep results small. Complete jobs with compact payloads or references; store large artifacts in storage or the database.
- Fetch results within the retention window. Terminal jobs are deleted about an hour after resolving; persist anything you need to keep.
Next Steps
- Executables - Backend functions that can start and resolve jobs
- AI agents - Run agent asks as trackable jobs with
askAsync() - Database - Persist job results beyond the retention window
- Distributed locks - Coordinate concurrent access to shared resources