# 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.`. 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 `/`. | 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 ```typescript /** Describes an action a source can perform. */ interface ActionDefinition { /** 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 } ``` `StandardSchemaV1` is the [Standard Schema](https://github.com/standard-schema/standard-schema) interface implemented by arktype, zod, and valibot. This means sources can use any validator: ```typescript 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 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 } ``` ### Changes to FeedSource Two optional fields added. Nothing else changes. ```typescript interface FeedSource { 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> /** Execute an action by ID. No-op returning { ok: false } if source has no actions. */ executeAction( actionId: string, params: Record, ): Promise } ``` ### Changes to FeedItem One optional field added. ```typescript interface FeedItem< TType extends string = string, TData extends Record = Record, > { 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. ```typescript class FeedEngine { // All existing methods unchanged... /** Route an action call to the correct source. */ async executeAction( sourceId: string, actionId: string, params: Record, ): Promise /** List all actions across all registered sources. */ listActions(): { sourceId: string; actions: readonly ActionDefinition[] }[] } ``` ### Example: Spotify Source ```typescript class SpotifySource implements FeedSource { 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): Promise { 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 { return null } // Note: for a source with no actions, it would be: // async listActions() { return {} } // async executeAction(): Promise { // return { ok: false, error: "No actions supported" } // } async fetchItems(context: Context): Promise { 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 1. `ActionDefinition` type exists with `id`, `label`, `description?`, `inputSchema?` 2. `ActionResult` type exists with `ok`, `data?`, `error?` 3. `ItemAction` type exists with `actionId`, `label?`, `params?` 4. `FeedSource.listActions()` is a required method returning `Record` (empty record if no actions) 5. `FeedSource.executeAction()` is a required method (no-op for sources without actions) 6. `FeedItem.actions` is an optional readonly array of `ItemAction` 7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult` 8. `FeedEngine.listActions()` aggregates actions from all sources 9. Existing tests pass unchanged (all changes are additive) 10. New tests: action execution, unknown action ID, unknown source ID, source without actions, `listActions()` aggregation ## Implementation Steps 1. Create `action.ts` in `aris-core/src` with `ActionDefinition`, `ActionResult`, `ItemAction` 2. Add optional `actions` and `executeAction` to `FeedSource` interface in `feed-source.ts` 3. Add optional `actions` field to `FeedItem` interface in `feed.ts` 4. Add `executeAction()` and `listActions()` to `FeedEngine` in `feed-engine.ts` 5. Export new types from `aris-core/index.ts` 6. Add tests for `FeedEngine.executeAction()` routing 7. Add tests for `FeedEngine.listActions()` aggregation 8. Add tests for error cases (unknown action, unknown source, source without actions) 9. Update source IDs to reverse-domain format (`"location"` → `"aris.location"`, etc.) across all source packages 10. Migrate `LocationSource.pushLocation()` → action `update-location` on `aris.location` 11. Migrate `TflSource.setLinesOfInterest()` → action `set-lines-of-interest` on `aris.tfl` 12. Add `async listActions() { return {} }` and no-op `executeAction()` to sources without actions (WeatherSource, GoogleCalendarSource, AppleCalendarSource) 13. Update any tests or code referencing old source IDs 14. Run all tests to confirm nothing breaks ## What This Defers - Transport layer (JSON-RPC over HTTP/WebSocket) — built when remote sources are needed - `RemoteFeedSource` adapter — 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)