Skip to content

Events and environment API

Three related pieces answer "what can my app do right now?": client.environment for a snapshot, client.services for the method-by-method picture, and client.events for a stream that tells you when either changes.

Availability

None of these need a permission. They describe what you already have, so they work in every workspace, connected or not.

Interface

ts
interface AppEnvironment {
  readonly apiVersion: 1;
  readonly client?: { readonly platform: "windows" | "macos" | "linux" | "android" | "ios" | "web" | "unknown" };
  readonly connection: "local" | "connected" | "disconnected" | "review-required";
  readonly binding: string | null;          // opaque identity of the current connection
  readonly workspaceId?: string | null;     // opaque identity of the configured target; "local" without a session
  readonly host?: { readonly name: string; readonly system: string; readonly target?: string };
  readonly visible: boolean;
  readonly capabilities: readonly string[];
  readonly operations?: Readonly<Record<string, readonly string[]>>;
}

interface ServiceMethodInfo {
  readonly name: string;                    // for example "system.files.readText"
  readonly version: number;
  readonly source?: string;                 // present for custom services
  readonly permissions: readonly string[];
  readonly granted: boolean;                // your app holds every permission it needs
  readonly available: boolean;              // the service can serve it right now
}

interface AppEvent      { readonly sequence: number; readonly topic: string; readonly value: Json }
interface AppEventBatch { readonly cursor: number; readonly reset: boolean; readonly events: readonly AppEvent[] }

client.environment.get(signal?: AbortSignal): Promise<AppEnvironment>;
client.services.list(signal?: AbortSignal): Promise<readonly ServiceMethodInfo[]>;
client.events.subscribe(listener: (batch: AppEventBatch) => void | Promise<void>, onError?: (error: unknown) => void): () => void;

connection is the state that matters most:

ValueMeaning
localThe local desktop workspace. No host at all.
connectedA host is connected and accepted; remote services may work.
disconnectedThe workspace exists but its connection is down.
review-requiredA reconnected host is waiting for the user to accept it.

capabilities lists what the provider offers, using the same names as permissions where they overlap: terminal, files.read, files.edit, files.create, files.manage, files.move, files.copy, files.folders, files.upload, files.download, host.settings.

Two topics are published: system.environment carries a fresh AppEnvironment, and system.services is a signal that the service list changed. A batch with reset: true means "this is the current state", not a replay of history.

Examples

Render from the environment, and keep rendering as it changes:

ts
function apply(env: AppEnvironment): void {
  const connected = env.connection === "connected";
  browseButton.disabled = !connected || !env.capabilities.includes("files.read");
  shellButton.disabled = !connected || !env.capabilities.includes("terminal");
  hostLabel.textContent = env.host ? `${env.host.name} · ${env.host.system}` : "No host";
}

apply(await client.environment.get());

const unsubscribe = client.events.subscribe(
  (batch) => {
    for (const event of batch.events) {
      if (event.topic === "system.environment") apply(event.value as unknown as AppEnvironment);
      if (event.topic === "system.services") void refreshMenus();
    }
  },
  (error) => console.warn("Event stream stopped", error),
);

Call unsubscribe() when your view goes away. The SDK shares one poll between all listeners, gives a late subscriber a current snapshot, and keeps running if one listener throws.

Gate a feature on a specific method rather than guessing:

ts
const methods = await client.services.list();
const canSave = methods.some((m) => m.name === "system.files.saveText" && m.granted && m.available);

granted and available answer different questions — "may I?" and "can it work?" — and your interface usually wants to distinguish them: hide what was never granted, and explain what is temporarily unavailable.

Errors

CodeMessageMeaning
busyAn event read is already waiting.Two subscriptions at the transport level; use one and fan out.
closedApp events have closed.The window is going away.
invalidInvalid event cursor.A cursor from another session; resubscribe.
abortedEvent wait canceled.Normal during teardown.

The onError callback receives these; the stream ends rather than retrying forever, so resubscribe if your app intends to keep running.

Lifecycle and permissions

The journal keeps the most recent 128 events, and each event value is bounded, so a burst of changes cannot flood your window. If your app was not listening, the next batch arrives as a reset snapshot rather than a backlog — always accept snapshots and re-render from them.

Discovery does not grant anything: an entry with granted: false tells you the permission is missing, not that you may call it. After a reconnect the binding identity changes; re-acquire locations and handles instead of reusing the ones you held.

ShellCanvas documentation