ARCHITECTURE
Views Agent
ShippedThe Views agent is Aero's domain agent for Views — the saved filter + column + grouping configurations users build to browse projects, tasks, customers, and other OnRamp objects. It creates and edits views through a human-in-the-loop draft flow, edits live views directly with confirmation, and answers data-shaped questions inline through a read-only SQL gate. It runs on Strands inside the copilot runtime, dispatched by a global intent router.
copilot peers
4
Views is one of four domain agents the router dispatches to
model-facing tools
24
across 8 groups, from matchers to canvas CRUD
intent paths
3
A edit · B create · C the reactive ladder
SSE event types
6
progress, snapshots, refresh, navigation, chips
01 · WHERE IT SITS
A peer among peers
There is no agent hierarchy. The copilot runtime registers four peer domain agents from runtimes/copilot/agents.yaml; the router (agents/_router/) is infrastructure that classifies each user message and dispatches to one of them. It auto-enables whenever a runtime lists two or more agents. Two more domain agents live in separate runtimes (intel, portal) and never share a conversation with Views.
_router
Haiku classifier · RouterDecision {domain, handoff_context} · prompt generated from agent manifests at startup
views
View Builder
Views CRUD + outcome-framed data questions
playbooks
Playbook Editor
Onboarding template authoring
onramp_chat
OnRamp Chat
General product Q&A and fallback
insights
Insights
Chart-scoped analytics Q&A
re_engagement
intel runtime
Stalled-project analysis
portal
portal runtime
Customer-facing portal help
router → views
RouterDecision
Haiku classifier picks the domain; manifest-generated criteria
views → router
return_to_router
sets active_domain="router"; same-turn loop re-dispatches (max 3 hops)
views → insights
navigate_to_insights
ladder matched an existing chart — pivot instead of rebuild
agent_state["active_domain"] = "router" (the return_to_router tool) and returns; the runtime's same-turn dispatch loop sees the change, re-runs the router, and dispatches the next agent — capped at 3 hops per turn so two agents can't ping-pong. onramp_chat uses exactly this to push view create/edit questions to the Views specialist. runtimes/copilot/agents.yaml · runtimes/copilot/main.py · agents/_router/agent.py · agents/views/tools/nav.py:47-58 · agents/onramp_chat/prompts/prompt.md
02 · WHAT IT ENTERTAINS
Three intents, one ladder
Every turn, the prompt classifies the message into one of three intents. The classification decides the flow — and whether the reactive ladder runs at all.
Intent A · edit-shaped
Modify the view you're on
On a view + an edit verb (add, remove, change, sort … instead, filter to, only show). Runs Path B: confirm once, then write straight to the live view with update_view_directly — no draft, which is why it confirms first.
"add a status filter" · "only show overdue ones"
Intent B · explicit create
Build a new view
A clear create verb (create a view, build me a view, new view). Runs Path A: propose in plain language first, write a draft only after the user approves, apply only when they say so.
"create a view of overdue projects" · "build me a view of customers behind on payments"
Intent C · data question
Answer, then offer
Interrogative phrasing with no edit/create verb (show me, how many, which, list, what's the average). Runs the reactive ladder below. A new View is a follow-up affordance, never the first move.
"how many tasks are blocked?" · "which customers are getting stuck?"
The reactive ladder — three answer surfaces
For a data question the agent checks three surfaces: an existing View, an existing Insights chart, and inline SQL results — fanned out in parallel. The matchers are enrichers, not gates: the data answer renders first, matched artifacts append as inline links, and the matchers are deterministic scorers, not LLM calls.
find_matching_view
Jaccard similarity of question tokens vs. each view's name (0.6), description (0.2), and filter fields (0.2); match threshold 0.5, with an object-type keyword gate so a task question never matches a customer view. Weights are git-tracked constants tuned by eval runs — no env vars.
find_matching_insight
Keyword score against the in-memory Insights chart catalog; threshold 0.4 and the winner must beat the runner-up by 0.2. On a match the agent appends the chart as an inline link — or pivots to the Insights agent for chart-scoped follow-ups.
Before offering to save an inline answer as a view, the agent runs a feasibility check (can_object_type_express_columns / can_view_express_query): every SQL column must be expressible as a view field. If not, it looks for a substitute field — same attribute, different point (planned vs. actual start date) — and names the gap in one sentence; with no substitute it sends the Known-Gap message and offers no save chip at all.
When it hands off
- Back to the router (
return_to_router) — the user pivots out of view work entirely: playbook edits, settings, general product questions. - To Insights (
navigate_to_insights) — the ladder matched an existing chart and the user wants the chart, not a view. - Uncertain intent → chips, not guesses — on-view: Edit this view Show me the data; off-view: Build me a view Just answer the question. The prompt prefers answering over guessing an edit: answering is recoverable, a wrong edit is not.
Not today
- No removals via merge — column and grouping merges are add/upsert only; the agent cannot drop a column or grouping level from an existing view.
- Flat filters only — the agent writes a single AND/OR condition list; nested filter groups (which the Views UI itself supports) are outside its vocabulary.
- No
thisYearoperator — period filters are two explicit ISO-date conditions (greaterEqualsperiod start +lessnext period start) computed from the session's date anchor. - "Today" is session-start — the date anchor is resolved when the session begins; a cross-midnight session keeps yesterday's anchor.
- Vendor users only — the internal endpoints reject portal/customer users.
- Inline answers cap at 1,000 rows — the SQL gate truncates; the prompt's presentation rules forbid passing a capped sample off as the whole.
views_agent.md:37-121,245 · tools/routing.py · matcher_weights.py · tools/canvas.py (merge semantics) · shared/prompts/today_context.py
03 · HOW IT WORKS
Question to Final Answer — two example flows
“How are my projects doing this year?” (user is not on a view)
- 1User asks in the Aero panel
- 2Router classifies the intent
- 3pre_dispatch grounds the turn
- 4The reactive ladder fans out
- 5SQL executes through the read-only gate
- 6Progress streams live
- 7Answer first, then enrich
- 8Chips + telemetry close the turn
Frontend
User asks in the Aero panel
The chat panel sends the message to the copilot runtime with the user’s JWT. The active page contributes ui_context (route, current view id if any) on every turn.
Contractchat request → copilot runtime · Authorization: Bearer JWT · ui_context payload
app/ui-organization (Aero panel) · runtimes/copilot/main.py
04 · ANATOMY
What makes a Views agent
PROMPT
System prompt — 485 lines of operating doctrine
agents/views/prompts/views_agent.md, assembled at build time as base prompt + the date fragment + the SQL kit's UX guardrails. Its load-bearing rules:
"Views are ONE of three answer surfaces you can offer, not your default first move."
"There is NOthisYear,lastYear, orthisQuarteroperator — never invent an operator key (the write is rejected with the bad key named)."
"Write your text response FIRST. Then call emit_suggest_replies LAST. Empty bubble + floating chips is a broken UX."Sections: domain → drafts vs. direct edits → reactive routing (intent rules + a worked classification table) → output contract → tools → argument shapes & operator vocabulary → the hard ordering rule → Path A / Path B turn scripts → chip vocabulary → linking rules → out-of-scope → data-presentation rules.
views_agent.md:1-485 · agent.py:141
TOOLS
24 model-facing tools, 8 groups
Canvas CRUD 7
create_view_draftupdate_view_draftapply_view_draftdiscard_view_draftget_active_draftget_current_viewupdate_view_directly
Routing ladder 5
find_matching_viewfind_matching_insightcan_view_express_querycan_object_type_express_columnsnavigate_to_insights
Fields & listing 3
list_fields_for_object_typelist_supported_object_typeslist_my_views
SQL kit (shared) 3
read_via_sqldescribe_schemadescribe_lookup_table
Navigation 2
return_to_routeropen_view_in_draft_mode
State recording 2
record_ladder_branchrecord_sql_error_category
Chips 1
emit_suggest_replies
Entity links (shared) 1
build_entity_urls
Tool factories close over vendor_id / user_id / agent_state, so the model-facing signatures carry only what the model should decide — identity never appears in a tool argument.
agent.py:99-138 · tools/*.py
RUNTIME
Live process — server-driven, zero model tokens
No "thoughts" stream. A Strands hook maps 13 tool stages to plain-language rows ("Looking into your data", "Building your view") plus two model-phase rows ("Thinking through your question", "Responding"). When the first mapped tool of one of 8 scenarios starts, the whole scenario's rows appear at once — trigger in-progress, the rest pending — so the user sees the full plan immediately.
TickingToolExecutor emits a synthetic heartbeat every 0.12s during tool batches so events drain live instead of bursting at batch completion. Canvas writes emit view_section_snapshot per section, painting the View editor section-by-section before view_refresh_requested reconciles the full state.
progress_stages.py:44-140 · ticking_executor.py:39 · tools/canvas.py:189-226
PROMPT
Time context — a grounded "today"
today_context_fragment() appends the ISO UTC date to the system prompt at agent build (session start), with an explicit instruction not to infer the date from training data — the date alone isn't enough to override the model's prior on "what year it is". This anchors every "this year" / "last month" window the agent computes.
shared/prompts/today_context.py · agent.py:141
RUNTIME
SQL — read-only, allowlisted, categorized
The shared SQL read kit (vendor-user scope) POSTs to Flask's internal SQL endpoint, where the rewriter enforces the ORM-declared column allowlist and tenant scoping. The kit also contributes its own prompt fragment (the SQL UX rules) and a lookup-FK guard hook.
On failure the model records a category — user_scope_denied, column_not_exposed, query_too_complex, internal_error — for telemetry, and the user sees a friendly message, never rewriter vocabulary. Status and picklist filter values are encoded allowed_values ids, never English labels.
shared/tools/sql_read.py:4,162-267 · views_agent.md:141-153,257
CONTRACTS
Internal endpoints — the agent's write surface
| Method | Path | Purpose |
|---|---|---|
| POST | /api/internal/views/draft | create draft (or clone from parent) |
| GET / PUT / DELETE | /api/internal/views/draft/{id} | read / merge-update / discard draft |
| POST | /api/internal/views/draft/{id}/apply | promote draft to live view |
| GET / PUT | /api/internal/views/{view_id} | read / direct-edit a live view |
| GET | /api/internal/views/object-types | supported object types (flag- and role-gated) |
| GET | /api/internal/views/object-fields/{id} | fields for an object type |
| GET | /api/internal/views/my-views | compact view list for duplicate detection |
Every id-bearing route also accepts hashids. Auth is a Bearer JWT forwarded from the agent (not Flask-Login): the controller validates the user exists, is active, is vendor-side, and belongs to the claimed vendor — the cross-tenant guard. Errors are typed (401/403 auth, 404 draft/view/parent not found, 409 draft already exists) and tools translate them to friendly strings the model can reason about.
app/api/views/routes.py:50-71,253-336 · controllers/internal_draft_controller.py · agents/views/_views_api.py
CONTRACTS
Response shaping — chips are a locked vocabulary
Chip strings are a case-sensitive contract between the lifecycle, the prompt, and the FE renderer. The lifecycle emits them from the state the user is in next, in five states: terminal (no chips) · draft written this turn (Apply viewChange Discard — lifecycle OWNS these, stray model chips are ignored) · model chips on a no-write turn (model wins) · active draft on a read turn (draft chips again) · proposal-mode fallback (Approve DraftChange Reject).
Ladder branches map to their own chip sets — e.g. sql_inline_offview → Save as a new view; sql_inline_onview_can_express → Update this view Create a new view. Matched views and insights are appended as inline markdown links (via build_entity_urls), never navigation chips. The tool accepts at most 4 options of up to 60 characters each.
lifecycle.py:208-327 · views_agent.md:333-396 · tools/emit.py
RUNTIME
Session state & memory
Per-turn, pre_dispatch syncs the FE's ui_context into agent_state (current_view_id, current_view_name, current_object_type_id, is_draft_active), clears the previous turn's ladder branch, and — when the user navigated to a different view — drops active_draft_id and the per-object-type fields_cache.
The draft-id bridge: if the user is already viewing a draft, that draft becomes the active one, so edits update it in place instead of minting a second draft (which the backend rejects with 409). Conversation history is summarized by Strands (ratio 0.3, last 10 messages preserved) on top of AgentCore memory.
lifecycle.py:52-159 · agent.py:143-146,176
OBSERVABILITY
Telemetry — every turn lands on a span
post_turn tags the turn's OTel span with intent.classification (data / edit / create / uncertain — from the recorded ladder branch, or inferred from which write tool fired), ladder.branch (the exact rung token, powering drill-downs into one branch's behavior), and sql.error_category. Matcher calls tag match result, score, top candidate and threshold. EVENT_FIELDS surfaces current_view_id and active_draft_id on session/done events for correlation.
lifecycle.py:35-49,162-205 · agent.py:150-161 · tools/routing.py