Skip to content
Night Owls.dev
Jump to a page
Concept

One roster. Not every caller sees it all.

Audiences are a per-caller visibility axis on agents: an optional string set declared per agent, intersected against a host-resolved set carried on the caller's context. They let one deployment serve a public support agent alongside admin-only editorial agents from the same tenant roster, without the public caller ever seeing, addressing, or delegating into the restricted ones.

The fourth identity axis

Audiences are the fourth identity axis, orthogonal to the three that already exist: tenant isolation, thread participation, and tool grants. The host owns both the audience vocabulary and the caller-to-agent mapping, exactly as it owns resolveTenant. Audiences are not authored content: they are configured on the agent's head row and resolved per request, so opening or closing an agent to an audience is an operator action, not a new published version.

AxisScopeEnforced by
tenantIdhard tenant isolationevery storage query
userIdthread participationruns.resolveContainerAccess (R14)
capabilitiestool/action grantstool gate
audienceswhich agents a caller may reachthe roster / addressing / resume / delegation seams

The two-step opening

An agent is reachable by a restricted caller only when both sides opt in. Nothing is reachable by accident.

Step 1. The host resolves the caller's audiences onto the context. Wherever you already build your AuthContext (your resolveTenant or auth middleware), add audiences.

auth.ts
// Host auth resolution. You own this mapping.
function resolveAuth(req): AuthContext {
  const user = authenticate(req);
  if (!user) {
    // An anonymous visitor of the public site.
    return { tenantId: SITE_TENANT, userId: "anon", audiences: ["public"] };
  }
  if (user.role === "admin") {
    // Trusted operator: omit audiences entirely (see "undefined = unrestricted" below).
    return { tenantId: user.tenantId, userId: user.id };
  }
  // A signed-in but non-admin member.
  return { tenantId: user.tenantId, userId: user.id, audiences: ["public", "members"] };
}

Step 2. The operator opens an agent to one or more audiences. By default a published agent is reachable by trusted callers only (see below); open it explicitly.

open-audience.ts
// Open the support agent to the public audience (operator action, head-row, not a new version).
// The writable surface is `storage.agentsWritable` (a VersionedRepo; the supabase adapter provides
// it, read-only / in-memory adapters don't). `storage.agents` is the read-only AgentRepo view of the
// same object. `actor` is the operator, recorded for audit; an `agent` principal is rejected
// (assertActorMayMutateDefinition), only human/service/system actors may open exposure.
const operator: SwarmActor = { type: "human", userId: adminUserId, tenantId };
await storage.agentsWritable?.setAudiences(tenantId, "support", ["public"], operator);

You can also seed the default at declaration time: defineAgent({ ..., audiences: ["public"] }) seeds the head row once, on first publish. After that, setAudiences is the source of truth.

With both steps done, a ["public"] caller reaches support (audiences intersect) but not editor (still trusted-only). The editor agent never appears in that caller's roster, cannot be addressed by slug, and cannot be delegated into.

The mental model: callerMayReach

Every reachability seam consults one predicate: callerMayReach(callerAudiences, agentAudiences).

CallerAgentReaches?Meaning
undefinedanythingYesUnrestricted / trusted. Today's back-compat behavior; reaches every agent.
["public"]["public", "members"]YesNon-empty intersection.
["public"]["admin"]NoDisjoint: restricted caller, unreachable agent.
["public"]null, [], or absentNoTrusted-only agent. Reachable only by unrestricted (undefined) callers.
[]anythingNo (+ warn)Restricted-matches-nothing. See the footgun below.

The rule in one line: undefined on the caller means trusted, it reaches everything; absent or [] on the agent means trusted-only, reached by nothing restricted. That trusted value is deliberately opposite on the two sides: undefined for the caller is the fail-open, back-compat default, while absent or [] for the agent is the safe, closed default. It is the two empty or absent values that fail closed: caller [] reaches nothing, and an agent's [] or absent is trusted-only, while caller undefined stays the open, trusted default.

The [] footgun

The single most important thing to get right:

An empty caller audience array ([]) reaches nothing. It is not “trusted”.

Hosts naturally produce [] from a filter:

auth.tsthe footgun
// FOOTGUN: a user with no audience-granting roles yields [], NOT undefined.
audiences: user.roles.filter((r) => AUDIENCE_ROLES.includes(r))

If that filter returns [], the caller now reaches no agents at all. Every roster is empty; every ask is AudienceForbidden.

The tempting fix is to collapse the empty result to undefined, but that is a privilege escalation, not a fix. undefined means unrestricted, so resolved.length ? resolved : undefined makes every caller who lacks an audience-granting role trusted, and a trusted caller reaches the whole roster, admin agents included. That would open exactly what audiences exist to close.

undefined must be gated on an independent determination that the caller is a trusted operator, never on the filter being empty. A non-operator with no audience roles must stay restricted (given a floor like ["public"], or denied outright):

auth.tsthe fix
// Correct: `undefined` (trusted) is gated on an INDEPENDENT operator check, NOT on filter emptiness.
const resolved = user.roles.filter((r) => AUDIENCE_ROLES.includes(r));
audiences: isTrustedOperator(user)          // e.g. the explicit `user.role === "admin"` branch from Step 1
  ? undefined                               // trusted operator, reaches everything
  : resolved.length ? resolved : ["public"] // everyone else stays restricted, never undefined

The safe model is the explicit branch from Step 1 (if (user.role === "admin") ...): decide trust from who the caller is, and only then grant undefined. An empty role-filter is a restricted caller, not a trusted one.

The engine fires a one-time process warning the first time any caller resolves audiences: [], precisely because this is the mistake hosts make. If you see [nightowls] a caller resolved audiences: [], this reaches NO agent (fail-closed) in your logs, this is the cause.

The asymmetry is intentional. On the agent side, absent or [] means trusted-only (a closed default is safe for a resource); on the caller side, undefined, not [], is the trusted value. An unrestricted principal must be stated as unrestricted, and an accidental empty set must fail closed rather than silently grant everything.

Recipe: serve a public agent to anonymous callers

The canonical use case: a public support or contact chatbot alongside an admin-only roster.

recipe.ts
// 1. Host: anonymous -> public. `undefined` (trusted) ONLY for real operators; every other signed-in
//    user stays restricted. Gate trust on WHO the caller is, never on "is authenticated".
function resolveAuth(req): AuthContext {
  const user = authenticate(req);
  if (!user) return { tenantId: SITE_TENANT, userId: "anon", audiences: ["public"] };
  if (user.role === "admin") return { tenantId: user.tenantId, userId: user.id }; // operator -> undefined = trusted
  return { tenantId: user.tenantId, userId: user.id, audiences: ["public"] };       // non-admin member: restricted
}

// 2. Operator: open ONLY the support agent to the public audience (audited, actor-gated).
const operator: SwarmActor = { type: "human", userId: adminUserId, tenantId: SITE_TENANT };
await storage.agentsWritable?.setAudiences(SITE_TENANT, "support", ["public"], operator);
// (editor, publisher, and others are left trusted-only; never touched.)

Now an anonymous POST /api/chat targeting support runs. The same request targeting editor returns AudienceForbidden before any side effect, and listAgents for that caller returns only support. Relaxing your auth gate to admit the anonymous caller no longer exposes the privileged roster, because the audience filter, not the tenant boundary, decides which agents they reach.

Keep restricted and trusted callers on separate threads

Resume, answering a HITL ask or waking a durable run, is both-must-pass. When either the run's start-time audiences or the resumer's audiences is restricted, the resume gate runs two independent reachability checks against the agent's current head, callerMayReach(start-time audiences, head) and callerMayReach(resumer audiences, head), and both must pass. The continuation then re-drives under intersectAudiences(start-time, resumer), so it can only ever get more restrictive, never wider. (When the run started unrestricted and the resumer is unrestricted, the gate is skipped entirely: byte-identical to pre-audiences behavior.) Two consequences:

  • A run stays as restricted as it started. If a ["public"] caller starts a run and an unrestricted operator later answers its ask, the continuation re-drives under ["public"] (the intersection of undefined and ["public"]). It does not widen to the operator's trust: a parked public run can never fan into admin agents just because an admin resumed it.
  • Narrowing an agent denies a restricted run's own resume. If a run started restricted and an operator later narrows the agent's audiences, the start-time snapshot is re-checked against the new head, so the parked run is denied on resume even to a resumer who reaches the new set. (A run that started unrestricted and is resumed unrestricted is unaffected: the gate is skipped, and a trusted principal could reach the narrowed agent directly anyway.)

Because of this, keep the public support conversation and the admin conversation on separate threads. Co-threading restricted and trusted callers means the thread's runs are pinned to the most-restrictive start-time audience, and resumes from the other principal will surprise you with AudienceForbidden.

agent-kit defaults to trusted-only

The pre-built @nightowlsdev/agent-* packages, kit, builder, researcher, marketer, designer, and writer, ship with no audiences: trusted-only by default. Installing a pack does not expose any of its agents to a restricted audience; you must call setAudiences (or seed audiences at defineAgent) to open a specific one. This is the safe default: a freshly-installed pack is reachable only by your unrestricted operators until you deliberately open an agent.

API reference

  • AuthContext.audiences?: string[], the host-resolved caller audiences. undefined means unrestricted (trusted, the back-compat default); [] reaches nothing (plus a one-time warning); a set reaches agents whose audiences intersect.
  • AgentSpec.audiences?: string[] (defineAgent), seeds the agent head row's audiences on first publish. Not part of versioned content: AgentVersionContent = Omit<AgentVersion, "version" | "audiences">.
  • storage.agentsWritable?.setAudiences(tenantId, slug, audiences, actor), the operator mutation. It lives on the writable VersionedRepo surface storage.agentsWritable (optional: the supabase adapter provides it, read-only / in-memory adapters don't), not on the read-only storage.agents (AgentRepo) view. actor: SwarmActor; it calls assertActorMayMutateDefinition(actor) first (an agent-type principal is rejected, it can never widen its own exposure), is audited, and emits NOTIFY. It is the runtime source of truth after seeding. (storage-supabase also exports a standalone setAgentAudiences(ctx, { tenantId, slug, audiences, actor }) convenience; the in-memory store's 3-arg setAgentAudiences is a test-only seeding helper.)
  • callerMayReach(callerAudiences: string[] | undefined, agentAudiences: string[] | null | undefined): boolean, the reachability predicate consulted at every seam (roster, addressing, resume, delegation, workflow steps). An agent's null, [], or absent audiences are all trusted-only.
  • intersectAudiences(a?, b?): string[] | undefined, computes the resumed continuation's audiences from the start-time and resumer sets: undefined AND undefined = undefined, undefined AND S = S, otherwise the (possibly empty) intersection, so a resumed run never widens. (The both-must-pass resume gate itself is two independent callerMayReach checks against the current head; this sets what the continuation then runs under.)
  • AudienceForbidden, thrown before side effects when a caller cannot reach a targeted agent. Carries stage = "forbidden", so durable and workflow runners treat it as terminal, never retryable.

Engine authors: audience filtering is a MUST in the engine contract. See E20 in ENGINE-CONTRACT.md and the verifyEngineContract harness.