Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b26263e
docs: design for generic writable custom collections
balegas Jun 10, 2026
3dee168
docs: implementation plan for generic writable custom collections
balegas Jun 10, 2026
bbe1118
feat(agents-runtime): add writable flag to CollectionDefinition
balegas Jun 10, 2026
8199a7f
feat(agents-runtime): materialize principal header into virtual column
balegas Jun 10, 2026
a60449c
fix(agents-runtime): strip principal virtual column before client wri…
balegas Jun 10, 2026
8dfb9e9
feat(agents-runtime): emit writable_collections at entity-type regist…
balegas Jun 10, 2026
6472e59
feat(agents-server): add writable_collections to entity type
balegas Jun 10, 2026
777e0c1
feat(agents-server): persist and resolve writable_collections
balegas Jun 10, 2026
d8aa2af
fix(agents-server): persist writable_collections as jsonb column
balegas Jun 10, 2026
e0f0625
feat(agents-server): generic writeCollection with principal-header st…
balegas Jun 10, 2026
0b3b8f2
refactor: rename writable collections to externally writable
balegas Jun 10, 2026
136e03a
fix(agents-server): add fork-work-lock guard to writeCollection
balegas Jun 10, 2026
a3a8f3f
feat(agents-server): POST /collections/:collection generic write route
balegas Jun 10, 2026
d517867
feat(agents-runtime): comments collection on generic externally-writa…
balegas Jun 10, 2026
4bdf8f3
feat(agents): declare comments as externally-writable state on horton…
balegas Jun 10, 2026
d87086d
feat(agents-runtime): project comments collection into timeline via _…
balegas Jun 10, 2026
8321189
fix(agents-runtime): use named imports for CommentTargetValue and Com…
balegas Jun 10, 2026
9ad4ab0
feat(agents-server-ui): comments UI on generic externally-writable co…
balegas Jun 10, 2026
275a738
fix(agents-runtime): align timeline fallback sentinel with ~ convention
balegas Jun 10, 2026
4c8187b
chore: changeset for generic externally-writable collections
balegas Jun 10, 2026
91b7f52
fix(agents-server-ui): stamp _principal on optimistic comments for im…
balegas Jun 10, 2026
d58507b
fix(agents-server-ui): register comments custom collection on entity …
balegas Jun 10, 2026
31f2bb3
refactor(agents): move comments timeline projection out of the runtime
balegas Jun 11, 2026
f0e3d7e
refactor(agents): drop principalColumn configurability, fix _principa…
balegas Jun 11, 2026
d217f43
refactor(agents-server-ui): comments visibility via live query, share…
balegas Jun 11, 2026
29d778e
refactor(agents-server-ui): dedupe formatSender, move comment helpers…
balegas Jun 11, 2026
a8df99a
fix(agents): tolerate legacy principalColumn in registration, misc re…
balegas Jun 11, 2026
a687924
chore(agents-server): renumber externally-writable-collections migrat…
balegas Jun 11, 2026
6043ed8
fix(agents-server-ui): carry error discriminant on comment timeline rows
balegas Jun 11, 2026
13050d0
chore: drop design spec doc from PR
balegas Jun 11, 2026
633434c
refactor(agents-runtime): rename timeline extraSources to customSources
balegas Jun 11, 2026
517e779
test(agents-server): cover schema rejection on writeCollection; refre…
balegas Jun 11, 2026
9673112
chore: bump changeset entries to patch per team convention
balegas Jun 11, 2026
c9220df
feat(agents): gate comments per-agent via comments/v1 contract
balegas Jun 11, 2026
31db3c0
chore: shorten changeset message
balegas Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/generic-externally-writable-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@electric-ax/agents-runtime': patch
'@electric-ax/agents-server': patch
'@electric-ax/agents-server-ui': patch
'@electric-ax/agents': patch
---

Add generic externally-writable custom collections for agent entity state: collections opt in via `externallyWritable`, writes go through an authenticated schema-validated endpoint that stamps the principal into a read-only `_principal` column, and `createEntityTimelineQuery` can project them into the timeline via `customSources`. Comments are reimplemented as one such collection, gated per agent type through a reserved `comments/v1` contract that the UI keys its comment affordances on.
7 changes: 7 additions & 0 deletions packages/agents-runtime/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
getEntityState,
normalizeEntityTimelineData,
normalizeTimelineEntities,
TIMELINE_ORDER_FALLBACK,
} from './entity-timeline'
export {
canonicalPgSyncOptions,
Expand Down Expand Up @@ -82,6 +83,7 @@ export type {
export type {
EntityTimelineContentItem,
EntityTimelineData,
EntityTimelineCustomSource,
EntityTimelineInboxMode,
EntityTimelineQueryOptions,
EntityTimelineQueryRow,
Expand All @@ -96,4 +98,9 @@ export type {
IncludesInboxMessage,
IncludesRun,
} from './entity-timeline'
export { COMMENTS_CONTRACT, commentsCollection } from './comments-collection'
export type {
CommentSnapshotValue as CommentSnapshot,
CommentTargetValue as CommentTarget,
} from './comments-collection'
export type { EntityTimelineEntry } from './use-chat'
91 changes: 91 additions & 0 deletions packages/agents-runtime/src/comments-collection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { z } from 'zod'
import type { CollectionDefinition } from './types'

export type CommentTargetValue =
| { kind: `comment`; key: string }
| {
kind: `timeline`
collection:
| `inbox`
| `run`
| `text`
| `tool_call`
| `wake`
| `signal`
| `manifest`
key: string
run_id?: string
}

export type CommentSnapshotValue = {
label: string
text?: string
from?: string
timestamp?: string
collection?: string
}

export type CommentValue = {
key?: string
body: string
timestamp: string
reply_to?: CommentTargetValue
target_snapshot?: CommentSnapshotValue
edited_at?: string
deleted_at?: string
deleted_by?: string
}

const commentTargetSchema = z.union([
z.object({ kind: z.literal(`comment`), key: z.string() }),
z.object({
kind: z.literal(`timeline`),
collection: z.enum([
`inbox`,
`run`,
`text`,
`tool_call`,
`wake`,
`signal`,
`manifest`,
]),
key: z.string(),
run_id: z.string().optional(),
}),
])

const commentSnapshotSchema = z.object({
label: z.string(),
text: z.string().optional(),
from: z.string().optional(),
timestamp: z.string().optional(),
collection: z.string().optional(),
})

export const commentSchema = z.object({
key: z.string().optional(),
body: z.string(),
timestamp: z.string(),
reply_to: commentTargetSchema.optional(),
target_snapshot: commentSnapshotSchema.optional(),
edited_at: z.string().optional(),
deleted_at: z.string().optional(),
deleted_by: z.string().optional(),
})

/**
* Contract identifier for the canonical comments collection. The server
* reserves the `comments` collection name for this contract, and the UI
* only surfaces comment affordances for entity types whose registration
* advertises it — so an agent's unrelated `comments` state can never be
* mistaken for platform comments.
*/
export const COMMENTS_CONTRACT = `comments/v1`

export const commentsCollection: CollectionDefinition = {
schema: commentSchema,
type: `state:comments`,
primaryKey: `key`,
externallyWritable: true,
contract: COMMENTS_CONTRACT,
}
208 changes: 115 additions & 93 deletions packages/agents-runtime/src/create-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
import type { WebhookSignatureVerifierConfig } from './webhook-signature'
import type {
AgentTool,
AnyEntityDefinition,
EntityStreamDBWithActions,
HeadersProvider,
ProcessWakeConfig,
Expand Down Expand Up @@ -207,6 +208,119 @@ export interface RuntimeDebugState {
export type RuntimeHandlerConfig = RuntimeRouterConfig
export type RuntimeHandlerResult = RuntimeHandler

const JSON_SCHEMA_KEYWORDS = [
`type`,
`properties`,
`items`,
`enum`,
`oneOf`,
`anyOf`,
`allOf`,
`additionalProperties`,
] as const

function stripSchemaKeyword(
jsonSchema: Record<string, unknown>
): Record<string, unknown> {
const { $schema: _schema, ...rest } = jsonSchema
return rest
}

function toJsonSchema(schema: unknown): Record<string, unknown> {
if (!schema || typeof schema !== `object` || Array.isArray(schema)) {
return {}
}

const standardSchema = schema as {
[`~standard`]?: {
jsonSchema?: {
input?: () => unknown
}
}
toJSONSchema?: () => Record<string, unknown>
}

const standardJsonSchema = standardSchema[`~standard`]?.jsonSchema?.input?.()
if (standardJsonSchema) {
return stripSchemaKeyword(standardJsonSchema as Record<string, unknown>)
}

if (typeof standardSchema.toJSONSchema === `function`) {
return stripSchemaKeyword(standardSchema.toJSONSchema())
}

if (`~standard` in standardSchema) {
return {}
}

const jsonSchemaLike = schema as Record<string, unknown>
if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) {
return stripSchemaKeyword(jsonSchemaLike)
}

return zodToJsonSchema(schema as any, { target: `jsonSchema7` })
}

function mapSchemas(
schemas: Record<string, unknown>
): Record<string, Record<string, unknown>> {
return Object.fromEntries(
Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)])
)
}

export function buildEntityTypeRegistrationBody(
name: string,
definition: AnyEntityDefinition
): Record<string, unknown> {
const stateEntries = definition.state ? Object.entries(definition.state) : []

const stateSchemas = Object.fromEntries(
stateEntries.map(([collectionName, def]) => [
def.type ?? `state:${collectionName}`,
toJsonSchema(def.schema ?? passthrough()),
])
)

const externallyWritableCollections: Record<
string,
{ type: string; contract?: string }
> = {}
for (const [collectionName, def] of stateEntries) {
if (!def.externallyWritable) continue
externallyWritableCollections[collectionName] = {
type: def.type ?? `state:${collectionName}`,
...(def.contract && { contract: def.contract }),
}
}

const body: Record<string, unknown> = {
name,
description: definition.description ?? `${name} entity`,
...(definition.creationSchema && {
creation_schema: toJsonSchema(definition.creationSchema),
}),
...(definition.inboxSchemas && {
inbox_schemas: mapSchemas(definition.inboxSchemas),
}),
...(definition.slashCommands && {
slash_commands: definition.slashCommands,
}),
state_schemas: {
...DEFAULT_STATE_SCHEMAS,
...stateSchemas,
...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}),
},
...(Object.keys(externallyWritableCollections).length > 0 && {
externally_writable_collections: externallyWritableCollections,
}),
...(definition.permissionGrants && {
permission_grants: definition.permissionGrants,
}),
}
return body
}

export function createRuntimeRouter(
config: RuntimeRouterConfig
): RuntimeRouter {
Expand Down Expand Up @@ -413,110 +527,18 @@ export function createRuntimeRouter(
return handleWebhookRequest(request)
}

const stripSchemaKeyword = (
jsonSchema: Record<string, unknown>
): Record<string, unknown> => {
const { $schema: _schema, ...rest } = jsonSchema
return rest
}

const JSON_SCHEMA_KEYWORDS = [
`type`,
`properties`,
`items`,
`enum`,
`oneOf`,
`anyOf`,
`allOf`,
`additionalProperties`,
] as const

const toJsonSchema = (schema: unknown): Record<string, unknown> => {
if (!schema || typeof schema !== `object` || Array.isArray(schema)) {
return {}
}

const standardSchema = schema as {
[`~standard`]?: {
jsonSchema?: {
input?: () => unknown
}
}
toJSONSchema?: () => Record<string, unknown>
}

const standardJsonSchema =
standardSchema[`~standard`]?.jsonSchema?.input?.()
if (standardJsonSchema) {
return stripSchemaKeyword(standardJsonSchema as Record<string, unknown>)
}

if (typeof standardSchema.toJSONSchema === `function`) {
return stripSchemaKeyword(standardSchema.toJSONSchema())
}

if (`~standard` in standardSchema) {
return {}
}

const jsonSchemaLike = schema as Record<string, unknown>
if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) {
return stripSchemaKeyword(jsonSchemaLike)
}

return zodToJsonSchema(schema as any, { target: `jsonSchema7` })
}

const registerTypes = async (): Promise<void> => {
const types = getRegisteredTypes()
const registered: Array<string> = []
const failed: Array<string> = []
const totalStart = performance.now()
const effectiveConcurrency = Math.max(1, registrationConcurrency ?? 8)

const mapSchemas = (
schemas: Record<string, unknown>
): Record<string, Record<string, unknown>> =>
Object.fromEntries(
Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)])
)

await forEachWithConcurrency(types, effectiveConcurrency, async (entry) => {
const registrationStart = performance.now()
const { name, definition } = entry

const stateSchemas = definition.state
? Object.fromEntries(
Object.entries(definition.state).map(([collectionName, def]) => [
def.type ?? `state:${collectionName}`,
toJsonSchema(def.schema ?? passthrough()),
])
)
: {}

const body: Record<string, unknown> = {
name,
description: definition.description ?? `${name} entity`,
...(definition.creationSchema && {
creation_schema: toJsonSchema(definition.creationSchema),
}),
...(definition.inboxSchemas && {
inbox_schemas: mapSchemas(definition.inboxSchemas),
}),
...(definition.slashCommands && {
slash_commands: definition.slashCommands,
}),
state_schemas: {
...DEFAULT_STATE_SCHEMAS,
...stateSchemas,
...(definition.stateSchemas
? mapSchemas(definition.stateSchemas)
: {}),
},
...(definition.permissionGrants && {
permission_grants: definition.permissionGrants,
}),
}
const body = buildEntityTypeRegistrationBody(name, definition)

const defaultDispatchPolicy = defaultDispatchPolicyForType?.(name)

Expand Down
Loading
Loading