Add listActions() and executeAction() to FeedSource for write operations back to external services. Actions use arktype schemas for input validation via StandardSchemaV1. - ActionDefinition type with optional input schema - FeedEngine routes actions with existence and ID validation - Source IDs use reverse-domain format (aris.location, aris.tfl) - LocationSource: update-location action with schema validation - TflSource: set-lines-of-interest action with lineId validation - No-op implementations for sources without actions Co-authored-by: Ona <no-reply@ona.com>
9.9 KiB
FeedSource Actions
Problem Statement
FeedSource is read-only. Sources can provide context and feed items but can't expose write operations (play, RSVP, dismiss). This blocks interactive sources like Spotify, calendar, and tasks.
Scope
aris-core only. Add action support to FeedSource and FeedItem. No changes to existing fields or methods — purely additive.
Design
Why Not MCP
MCP was considered. It doesn't fit because:
- MCP resources don't accept input context (FeedSource needs accumulated context as input)
- MCP has no structured feed items (priority, timestamp, type)
- MCP's isolation model conflicts with ARIS's dependency graph
- Adding these as MCP extensions would mean the extensions are the entire protocol
The interface is designed to be protocol-compatible — a future RemoteFeedSource adapter can map each field/method to a JSON-RPC operation without changing the interface:
| FeedSource field/method | Future protocol operation |
|---|---|
id, dependencies |
source/describe |
listActions() |
source/listActions |
fetchContext() |
source/fetchContext |
fetchItems() |
source/fetchItems |
executeAction() |
source/executeAction |
onContextUpdate() |
source/contextUpdated |
onItemsUpdate() |
source/itemsUpdated |
No interface changes needed when the transport layer is built.
Source ID & Action ID Convention
Source IDs use reverse domain notation. Built-in sources use aris.<name>. Third parties use their own domain.
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is <sourceId>/<actionId>.
| Source ID | Action IDs |
|---|---|
aris.location |
update-location (migrated from pushLocation()) |
aris.tfl |
set-lines-of-interest (migrated from setLinesOfInterest()) |
aris.weather |
(none) |
com.spotify |
play-track, pause-playback, skip-track, like-track |
aris.calendar |
rsvp, create-event |
com.todoist |
complete-task, snooze-task |
This means existing source packages need their id updated (e.g., "location" → "aris.location").
New Types
/** Describes an action a source can perform. */
interface ActionDefinition<TInput = unknown> {
/** Descriptive action name in kebab-case (e.g., "update-location", "play-track") */
readonly id: string
/** Human-readable label for UI (e.g., "Play", "RSVP Yes") */
readonly label: string
/** Optional longer description */
readonly description?: string
/** Schema for input validation. Accepts any Standard Schema compatible validator (arktype, zod, valibot, etc.). Omit if no params. */
readonly input?: StandardSchemaV1<TInput>
}
StandardSchemaV1 is the Standard Schema interface implemented by arktype, zod, and valibot. This means sources can use any validator:
import { type } from "arktype"
import { z } from "zod"
// With arktype
{ id: "play-track", label: "Play", input: type({ trackId: "string" }) }
// With zod
{ id: "play-track", label: "Play", input: z.object({ trackId: z.string() }) }
// Without validation (e.g., remote sources using raw JSON Schema)
{ id: "play-track", label: "Play" }
/** Result of executing an action. */
interface ActionResult {
ok: boolean
data?: Record<string, unknown>
error?: string
}
/** Reference to an action on a specific feed item. */
interface ItemAction {
/** Action ID (matches ActionDefinition.id on the source) */
actionId: string
/** Per-item label override (e.g., "RSVP to standup") */
label?: string
/** Pre-filled params for this item (e.g., { eventId: "abc" }) */
params?: Record<string, unknown>
}
Changes to FeedSource
Two optional fields added. Nothing else changes.
interface FeedSource<TItem extends FeedItem = FeedItem> {
readonly id: string // unchanged
readonly dependencies?: readonly string[] // unchanged
fetchContext(...): ... // unchanged
onContextUpdate?(...): ... // unchanged
fetchItems?(...): ... // unchanged
onItemsUpdate?(...): ... // unchanged
/** List actions this source supports. Empty record if none. Maps to: source/listActions */
listActions(): Promise<Record<string, ActionDefinition>>
/** Execute an action by ID. No-op returning { ok: false } if source has no actions. */
executeAction(
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
}
Changes to FeedItem
One optional field added.
interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
id: string // unchanged
type: TType // unchanged
priority: number // unchanged
timestamp: Date // unchanged
data: TData // unchanged
/** Actions the user can take on this item. */
actions?: readonly ItemAction[]
}
Changes to FeedEngine
Two new methods. Existing methods unchanged.
class FeedEngine {
// All existing methods unchanged...
/** Route an action call to the correct source. */
async executeAction(
sourceId: string,
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
/** List all actions across all registered sources. */
listActions(): { sourceId: string; actions: readonly ActionDefinition[] }[]
}
Example: Spotify Source
class SpotifySource implements FeedSource<SpotifyFeedItem> {
readonly id = "com.spotify"
async listActions() {
return {
"play-track": { id: "play-track", label: "Play", input: type({ trackId: "string" }) },
"pause-playback": { id: "pause-playback", label: "Pause" },
"skip-track": { id: "skip-track", label: "Skip" },
"like-track": { id: "like-track", label: "Like", input: type({ trackId: "string" }) },
}
}
async executeAction(actionId: string, params: Record<string, unknown>): Promise<ActionResult> {
switch (actionId) {
case "play-track":
await this.client.play(params.trackId as string)
return { ok: true }
case "pause-playback":
await this.client.pause()
return { ok: true }
case "skip-track":
await this.client.skip()
return { ok: true }
case "like-track":
await this.client.like(params.trackId as string)
return { ok: true }
default:
return { ok: false, error: `Unknown action: ${actionId}` }
}
}
async fetchContext(): Promise<null> {
return null
}
// Note: for a source with no actions, it would be:
// async listActions() { return {} }
// async executeAction(): Promise<ActionResult> {
// return { ok: false, error: "No actions supported" }
// }
async fetchItems(context: Context): Promise<SpotifyFeedItem[]> {
const track = await this.client.getCurrentTrack()
if (!track) return []
return [
{
id: `spotify-${track.id}`,
type: "spotify-now-playing",
priority: 0.4,
timestamp: context.time,
data: { trackName: track.name, artist: track.artist },
actions: [
{ actionId: "pause-playback" },
{ actionId: "skip-track" },
{ actionId: "like-track", params: { trackId: track.id } },
],
},
]
}
}
Acceptance Criteria
ActionDefinitiontype exists withid,label,description?,inputSchema?ActionResulttype exists withok,data?,error?ItemActiontype exists withactionId,label?,params?FeedSource.listActions()is a required method returningRecord<string, ActionDefinition>(empty record if no actions)FeedSource.executeAction()is a required method (no-op for sources without actions)FeedItem.actionsis an optional readonly array ofItemActionFeedEngine.executeAction()routes to correct source, returnsActionResultFeedEngine.listActions()aggregates actions from all sources- Existing tests pass unchanged (all changes are additive)
- New tests: action execution, unknown action ID, unknown source ID, source without actions,
listActions()aggregation
Implementation Steps
- Create
action.tsinaris-core/srcwithActionDefinition,ActionResult,ItemAction - Add optional
actionsandexecuteActiontoFeedSourceinterface infeed-source.ts - Add optional
actionsfield toFeedIteminterface infeed.ts - Add
executeAction()andlistActions()toFeedEngineinfeed-engine.ts - Export new types from
aris-core/index.ts - Add tests for
FeedEngine.executeAction()routing - Add tests for
FeedEngine.listActions()aggregation - Add tests for error cases (unknown action, unknown source, source without actions)
- Update source IDs to reverse-domain format (
"location"→"aris.location", etc.) across all source packages - Migrate
LocationSource.pushLocation()→ actionupdate-locationonaris.location - Migrate
TflSource.setLinesOfInterest()→ actionset-lines-of-interestonaris.tfl - Add
async listActions() { return {} }and no-opexecuteAction()to sources without actions (WeatherSource, GoogleCalendarSource, AppleCalendarSource) - Update any tests or code referencing old source IDs
- Run all tests to confirm nothing breaks
What This Defers
- Transport layer (JSON-RPC over HTTP/WebSocket) — built when remote sources are needed
RemoteFeedSourceadapter — mechanical once transport exists- MCP adapter — wraps MCP servers as FeedSource
- Runtime schema validation of action params
- Action permissions / confirmation UI
- Source discovery / registry API
- Backend service consolidation (separate spec, depends on this one)