Skip to main content

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);
note

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 CaseRecommendation
A backend function performs long work while a client waitsStart a job in the backend, return the ID, and awaitJob() on the client
Track the progress of a long-running AI agent askPass a jobId to ask() or use askAsync(), then await the job
Check the current status of ongoing workgetJob()
Keep UI in sync with database changesUse real-time queries instead
Run a function on a scheduleUse a scheduler instead

How it works

  1. The producer generates a unique, private job ID and calls startJob(jobId)
  2. 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
  3. Consumers call awaitJob(jobId) to wait for the outcome, or getJob(jobId) to poll the current status
  4. The producer resolves the job with completeJob(jobId, result) or failJob(jobId, error)
  5. 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

Backend code
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

Client code
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():

MethodReturnsAPI key requiredDescription
startJob(jobId)Promise<void>YesStarts a job owned by this client. Idempotent for an existing job.
completeJob(jobId, result)Promise<void>YesResolves a job this client owns successfully with its result
failJob(jobId, error)Promise<void>YesResolves a job this client owns as failed with an error message
getJob<T>(jobId)Promise<AsyncJob<T> | undefined>NoReturns the job's current status and, if completed, its result
awaitJob<T>(jobId)Promise<T>NoWaits until the job finishes; resolves with the result or throws

The AsyncJob type

getJob() returns an AsyncJob:

FieldTypeDescription
idstringThe job ID
createdAtDateWhen the job was started
updatedAtDateLast status change
status'in_progress' | 'completed' | 'failed'Current state of the job
resultT (optional)The result, present when status is 'completed'
errorstring (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:

Client code
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

ErrorCauseSolution
UNAUTHORIZED on startJob/completeJob/failJobThe client is not authenticated with an API keyStart and resolve jobs from the backend or a client initialized with an API key
NOT_JOB_OWNERA client tried to resolve a job started by another clientResolve jobs only from the client that started them
awaitJob rejects with the job's errorThe producer called failJob, or the owner disconnectedHandle the rejection; inspect the error message for the cause
awaitJob never settlesThe job ID is wrong, or the job expired (~1 hour after resolving) before the waiter subscribedVerify the job ID, await results promptly, and wrap the wait in your own timeout
Job fails with Failed to store job resultThe result exceeded the storage size limitStore large payloads in storage and complete the job with a reference

Best Practices

  1. Generate unguessable job IDs. The ID is the only access control on a job; use crypto.randomUUID(), never sequential or predictable values.
  2. Always resolve your jobs. Wrap the work in try/catch and call failJob() on error so waiters get a meaningful failure instead of a generic failover error.
  3. Start and resolve from the backend. Backend code is already authenticated with an API key and stays connected for the duration of the work.
  4. Keep results small. Complete jobs with compact payloads or references; store large artifacts in storage or the database.
  5. 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