Documents
sibling-scopes
sibling-scopes
Type
External
Status
Published
Created
Mar 17, 2026
Updated
Mar 17, 2026
Updated by
Dosu Bot

Child scopes point down — from parent to child. But sometimes scopes at the same level need to interact. A tools scope might need to register definitions into a modelContext scope. A composer scope might need to read from a thread scope.

These are sibling scopes — scopes that sit at the same level in the provider tree and reference each other.

The problem#

When you pass multiple scopes to useAui, they're all created as siblings:

const aui = useAui({
  thread: ThreadResource(),
  tools: ToolsResource(),
  modelContext: ModelContextResource(),
});

But what if ToolsResource needs to call methods on the modelContext scope? At definition time, the tools resource doesn't have access to its siblings.

tapAssistantClientRef#

tapAssistantClientRef() gives a resource access to the store being built. It returns a ref whose .current property points to the AssistantClient once all scopes are mounted:

import { resource, tapEffect } from "@assistant-ui/tap";
import { tapAssistantClientRef } from "@assistant-ui/store";
import type { ClientOutput } from "@assistant-ui/store";

const ToolsResource = resource((): ClientOutput<"tools"> => {
  const clientRef = tapAssistantClientRef();

  tapEffect(() => {
    // access a sibling scope
    const unsub = clientRef.current!.modelContext().register({
      getModelContext: () => ({ tools: myTools }),
    });
    return () => unsub();
  }, [clientRef]);

  return {
    getState: () => ({ ... }),
  };
});

Use tapAssistantClientRef in effects, not during the resource body. The ref is populated after all sibling scopes are mounted, so it's only safe to access in tapEffect or in methods called later.

attachTransformScopes#

tapAssistantClientRef lets a scope talk to siblings at runtime. But what if the sibling scope doesn't exist yet? If a user only provides thread: ThreadResource(), there's no modelContext to talk to.

attachTransformScopes solves this by letting a resource declare: "when I'm mounted, make sure these sibling scopes also exist."

import { attachTransformScopes } from "@assistant-ui/store";

attachTransformScopes(ToolsResource, (scopes, parent) => {
  // ensure modelContext exists as a sibling
  if (!scopes.modelContext && parent.modelContext.source === null) {
    scopes.modelContext = ModelContextResource();
  }
});

The transform function receives:

  • scopes — the current scopes config being built (mutate it directly)
  • parent — the parent AssistantClient (from the AuiProvider above)

It mutates the scopes object directly, adding scopes that should be created alongside.

Checking parent.source#

Before adding a scope, check parent.scopeName.source:

  • null — the scope isn't provided by any ancestor. Safe to add it here.
  • "root" or a scope name — an ancestor already provides this scope. Don't duplicate it.
attachTransformScopes(MyResource, (scopes, parent) => {
  // only add if not already in scopes AND not provided by a parent
  if (!scopes.tools && parent.tools.source === null) {
    scopes.tools = ToolsResource();
  }
});

Adding derived scopes#

Transforms can also add Derived scopes:

attachTransformScopes(ThreadResource, (scopes) => {
  scopes.composer ??= Derived({
    source: "thread",
    query: {},
    get: (aui) => aui.thread().composer(),
  });
});

This ensures that when a user provides a thread scope, the composer scope is automatically wired up as a child of that thread — without the user having to declare it.

Iterative application#

Transforms are applied iteratively. When a transform adds a new root scope, Store checks if that new scope also has a transform attached — and runs it too. This continues until no new scopes are added.

For example:

  1. User provides thread: ThreadResource()
  2. ThreadResource's transform adds tools: ToolsResource() and modelContext: ModelContextResource()
  3. ToolsResource's transform runs — sees modelContext already exists, does nothing
  4. No more new scopes → done
Each resource can only have one transform attached. Calling `attachTransformScopes` twice on the same resource throws an error.

When to use which#

PatternUse when
tapAssistantClientRefA scope needs to call methods on a sibling at runtime (effects, event handlers)
attachTransformScopesA scope needs to guarantee a sibling exists before mounting

In practice, they're often used together: attachTransformScopes ensures the sibling scope is created, then tapAssistantClientRef accesses it at runtime.