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 in an AuiConfig, they're all created as siblings:
AuiConfig({
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.
useAssistantClientRef#
useAssistantClientRef() 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 } from "@assistant-ui/tap";
import { useAssistantClientRef } from "@assistant-ui/store";
import type { ClientOutput } from "@assistant-ui/store";
import { useEffect } from "react";
const useToolsResource = (): ClientOutput<"tools"> => {
const clientRef = useAssistantClientRef();
useEffect(() => {
// access a sibling scope
const unsub = clientRef.current!.modelContext.register({
getModelContext: () => ({ tools: myTools }),
});
return () => unsub();
}, [clientRef]);
return {
getState: () => ({ ... }),
};
};
const ToolsResource = resource(useToolsResource);
Use useAssistantClientRef 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 useEffect or in methods called later.
attachTransformScopes#
useAssistantClientRef 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."
Attach the transform to the hook function you passed to resource() — transforms are keyed by the hook, not the resource wrapper:
import { attachTransformScopes } from "@assistant-ui/store";
attachTransformScopes(useToolsResource, (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 parentAssistantClient(from theAuiProviderabove)
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(useMyResource, (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(useThreadResource, (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:
- User provides
thread: ThreadResource() ThreadResource's transform addstools: ToolsResource()andmodelContext: ModelContextResource()ToolsResource's transform runs — seesmodelContextalready exists, does nothing- No more new scopes → done
When to use which#
| Pattern | Use when |
|---|---|
useAssistantClientRef | A scope needs to call methods on a sibling at runtime (effects, event handlers) |
attachTransformScopes | A scope needs to guarantee a sibling exists before mounting |
In practice, they're often used together: attachTransformScopes ensures the sibling scope is created, then useAssistantClientRef accesses it at runtime.