Every call creates or replays an execution with one of five states:

Text
pending → running → succeeded
                  ↘ failed
                  ↘ cancelled
pending ──────────→ failed
        └──────────→ cancelled

succeeded, failed, and cancelled are immutable terminal states. Terminal lifecycle state, output or error, latency, and timestamps cannot be overwritten. A separate monotonic store projection may later add replayed: true after an accepted replay without rewriting the terminal record.

Synchronous tools

tools.execute returns the execution envelope. tools.run waits when necessary and returns only the output:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const output = await eyeball.tools.run(
  "gmail.get_email",
  { messageId: "msg_123" },
  { userId: "demo_user" },
);
console.log(output);

Asynchronous tools

Tools annotated async: true return a pending envelope. Poll explicitly when you need progress:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const started = await eyeball.tools.execute("twilio.start_call", {
  userId: "demo_user",
  input: { to: "+12025550123", from: "+12025550124" },
  mode: "async",
  idempotencyKey: "call:reservation:42",
});
const terminal = await eyeball.executions.wait(started.executionId, {
  pollMs: 500,
  timeoutMs: 60_000,
});
console.log(terminal.status);

Use GET /v1/executions/:id to poll outside the SDK.

Cancellation

Cancel pending or running work with POST /v1/executions/:id/cancel or eyeball.executions.cancel(executionId). The returned record includes cancellation.dispatchMayHaveBegun:

  • false means cancellation won before the durable provider-dispatch marker. Provider invocation is fenced, and any reserved usage is released.
  • true means provider dispatch may already have begun. The executor aborts same-process stock HTTP work where possible, but the provider may still complete or apply an external side effect. The execution remains durably cancelled, and dispatched usage is reported once.

Repeated cancellation returns the same terminal record, including its original completedAt, error, latency, and disposition. A late adapter result cannot replace it.

Bounded execution provenance

Execution detail and list records may carry three optional, deliberately narrow provenance fields:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});
const terminal = await eyeball.executions.get(
  "exe_01JZ6WA0Q73ZQ5B51SRYB6M4Z8",
);

if (terminal.replayed === true) {
  console.log("An accepted replay reused this execution ID");
}

if (terminal.source?.kind === "voice_session") {
  console.log("Voice session", terminal.source.sessionId);
}

for (const fileId of terminal.attachments?.fileIds ?? []) {
  console.log("Historical staged file", fileId);
}

replayed appears only after an actual accepted idempotent replay and never carries the key or a derivative. A verified voice child carries its source session ID so the dashboard can navigate back to the exact session; callers cannot create that source metadata with a voice-looking execution ID or an arbitrary header. attachments contains only distinct staged Eyeball file IDs and their count. It is historical: the files may have expired, and the summary does not imply that bytes are still available.

Canonical input, connection selection, raw or derived idempotency identity, attachment names and MIME metadata, and file bytes remain private. Staged bytes are resolved only inside the authenticated adapter execution path.

Terminal webhooks receive the safe terminal execution record as projected when delivery work is claimed. A replay observed before that claim can therefore appear in the webhook; a later replay updates future GET/list projections but does not redeliver or rewrite webhook history.

Staged files and attachments

Stage bytes once, then pass the returned reference directly to a tool. The SDK accepts a Buffer, Uint8Array, or UTF-8 string and uploads it through POST /v1/files:

ts
import { readFile } from "node:fs/promises";
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

const attachment = await eyeball.files.upload({
  name: "receipt.pdf",
  mimeType: "application/pdf",
  content: await readFile("./receipt.pdf"),
});

await eyeball.tools.run(
  "gmail.send_email",
  {
    to: ["buyer@example.com"],
    subject: "Your receipt",
    body: "The receipt is attached.",
    attachments: [attachment],
  },
  { userId: "demo_user" },
);

The primary REST upload format is JSON with padded base64 content:

JSON
{"name":"receipt.pdf","mimeType":"application/pdf","content":"JVBERi0xLjcK...=="}

The response is { fileId, name, mimeType, size, expiresAt }. Both GET /v1/files/:id and the paginated GET /v1/files collection return metadata only. The collection is newest-first, omits expired files, defaults to 100 results, and accepts limit from 1 through 100 plus an opaque cursor:

ts
import { Eyeball } from "@eyeball/sdk";

const eyeball = new Eyeball({
  apiKey: process.env.EYEBALL_API_KEY!,
  baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});

let cursor: string | undefined;
do {
  const page = await eyeball.files.list({ limit: 25, cursor });
  for (const file of page.files) console.log(file.fileId, file.name);
  cursor = page.nextCursor;
} while (cursor);

The response shape is { files, nextCursor? }. Project-wide enumeration requires an unpinned project-authority API key; user-pinned keys receive 403 auth_insufficient_scope. A malformed, unknown, reclaimed, or cross-project cursor receives 422 invalid_input.

File bytes are internal-only and can be resolved only by an adapter executing inside the authenticated project. Files expire after one hour by default, and the decoded size limit defaults to 25 MiB. Self-hosted executors can override those limits with EYEBALL_FILE_TTL_MS and EYEBALL_FILE_MAX_BYTES. Without EYEBALL_DATABASE_URL, staged files are in memory and disappear on executor restart. With Postgres, metadata and bytea content survive restart only until expiry; the FileStore seam permits a future object-store backend without changing the public routes or execution-bound resolver. There is no public download route.

Staged files currently flow through Gmail and Microsoft Outlook send_email, reply_to_email, and create_draft, plus Google Drive upload_file. Other email providers return a clear not_supported error when non-empty attachments are supplied.