Inject live state

The model makes better decisions when it already knows your app's state — current selection, project settings, playback position. A context source pushes that state into the request itself, so the model doesn't spend a tool call (and a full round trip) just to ask.

Implement ContextSource and register it on the agent. Before each provider request, the session collects a one-line summary from every active source and injects it into the system prompt as [source-id]: text lines.

Define a source

A source has an id, a refresh policy, a short summary() for every request, and a richer detail() the model can request on demand.

struct SelectionContext: ContextSource {
    let id = "selection"
    let policy: ContextSummaryPolicy = .always
    let editor: EditorStore

    func summary() async throws -> ContextSummary {
        let names = await editor.selectedClipNames()
        let text = names.isEmpty
            ? "No clips selected"
            : "Selected: \(names.joined(separator: ", "))"
        return ContextSummary(sourceId: id, text: text)
    }

    func detail() async throws -> DomainContext {
        let names = await editor.selectedClipNames()
        return DomainContext(
            sourceId: id,
            content: [.json(.array(names.map { .string($0) }))]
        )
    }
}

EditorStore stands in for whatever owns your selection state. Register sources when you build the agent:

let agent = try runtime.makeAgent(
    provider: .anthropic(apiKey: key),
    role: AgentRole(staticPersona: "You are a precise video-editing assistant."),
    contextSources: [SelectionContext(editor: editor)]
)

Pick a refresh policy

ContextSummaryPolicy decides how often summary() runs:

Policy Refreshes Use for
.always before every provider request fast-changing state — selection, playback position
.cached(seconds) after the TTL expires, or when a tool invalidates it slow-changing state — project settings, library counts
.onDemand never automatically — only when the model asks expensive-to-compute state

.always and valid .cached summaries ride the system prompt on every request. .onDemand sources contribute nothing until the model reaches for them (see let the model dig deeper).

Invalidate after mutations

A cached summary goes stale the moment a tool changes what it describes. Declare which domains' mutations stale your source with invalidatedByDomains — the default is [id], the source's own id, so override it whenever your source watches a different domain:

struct TimelineOverviewContext: ContextSource {
    let id = "timeline_overview"
    let policy: ContextSummaryPolicy = .cached(60)
    var invalidatedByDomains: Set<String> { ["timeline"] }
    let timeline: TimelineStore

    func summary() async throws -> ContextSummary {
        let count = await timeline.clipCount()
        return ContextSummary(sourceId: id, text: "\(count) clips on the timeline")
    }

    func detail() async throws -> DomainContext {
        let count = await timeline.clipCount()
        return DomainContext(
            sourceId: id,
            content: [.json(.object(["clip_count": .integer(count)]))]
        )
    }
}

Two things invalidate the cache mid-turn, before the TTL expires:

  • a successful tool call from a domain declared .mutating invalidates every cached source that maps that domain in invalidatedByDomains
  • a tool result whose affectedEntities name a domain invalidates the cached sources mapped to that domain — even from a read-only domain

Either way, the next provider request rebuilds the summary, so the model sees the state its own tool call just changed.

Let the model dig deeper

Summaries are deliberately one line. When the model needs the full picture, two built-in meta-tools serve it without any code on your side:

Meta-tool Returns
agentkit.list_context every registered source with its id and policy
agentkit.inspect_context the detail() content for one source_id

This is also how .onDemand sources are reached: the model lists, inspects, and pays the cost only when it decides the detail is worth it.

When a source fails

A throwing source never fails the turn — live state is an enhancement, not a dependency:

  • an .always source that throws contributes a [context unavailable] placeholder for that request
  • a .cached source whose refresh throws after the TTL expires serves the last good summary instead
  • every failure is recorded in lastContextDiagnostics on the session, reset each turn — see watch it work for the diagnostic surface

Next