Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 23 additions & 8 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ data, the Card carries only the shape.
_Avoid_: surface (the protocol's word, and `surfaceId` in the code; in this
glossary the rendered thing is a Card instance)

**Ephemeral Card**:
A Card generated within a **Chat**, kept in its history across restarts and available
for experimentation with the same capabilities as a catalogued Card. Its definition
enters the **Card catalog** only after the user explicitly saves it as a **Profile card**.
_Avoid_: unsaved Card (ambiguous with a failed file write), temporary Card (does not
say whether it is rendered or catalogued)

**Card draft**:
One **Ephemeral Card** being iterated in a **Chat**, with its own identity and current
version. A Chat may contain several independent drafts; earlier versions remain in
history, and only the current version of each draft can be saved.
_Avoid_: active draft (there may be several), selected Card (no separate selection state)

**Card catalog**:
The set of Cards the agent is offered — what it may render into. Resolved for one
profile from the three card layers, minus whatever is **Disabled** or
Expand Down Expand Up @@ -189,17 +202,19 @@ _Avoid_: **File reference** (the `@`-pointer into a message), **Mentioned in** (
backlink from a file to the conversations touching it)

**Rich view** (the `rich-views` **Skill**):
The Bundled Skill the **Card catalog** is disclosed through. Its description is the only
A2UI text a turn carries at all, and names each Card's **topic** — the case it is ready
for; its body is the index of this profile's Cards and how to draw one; each Card is a
script of the Skill that draws it (ADR 0040); one Card's schema and worked call, and the
A2UI protocol at large, are resources read on demand. "Rich view" is what it is called where a user meets it, because the
switch is theirs: turned off, the profile is offered no Card and its turns carry no A2UI
runtime — while a Card instance already in a **Thread**, and a Card the server fills for
itself, go on drawing.
The Bundled **Skill** that discloses the **Card catalog** and lets the agent draw
instances of its available Cards. Turning it off stops new drawings through this
Skill; the independent **Card author**, historical instances and server-filled views
keep their own availability.
_Avoid_: A2UI skill (the protocol's name, not the user's), Card skill (that is the Skill
that *writes* Cards)

**Card author** (the `card-author` **Skill**):
The Bundled **Skill** for generating and revising **Ephemeral Cards** from the
**Card vocabulary**. It is enabled independently of **Rich view**; turning it off
prevents new authoring but leaves historical drafts available for explicit saving.
_Avoid_: Card skill, rich views (the rendered capability, not the authoring capability)

**Permissions**:
The security policy of allowed commands (command-prefix and whole-tool grants).
Commands only — folder access is the separate Folder/Grant system. Edited only by
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
status: accepted
date: 2026-10-05
---

# Card drafts live in Chat history until explicitly saved

Issue #88 supports generating and revising an Ephemeral Card in the same Turn through
a separate bundled `card-author` MemorySkill. Persist its validated definition and
expanded A2UI surface in the Chat's event log, so rendering, iteration and saving survive
restart without changing the Turn's held Card catalog (ADR 0041).

Each Chat can contain several Card drafts, each with a stable identity and one current
version; revisions append to history and supersede only the previous version of that
draft. Only current versions offer Save, which writes the definition as a Profile Card
after an explicit user request; current instance values remain in Chat history.
Restoring an older definition through conversation appends a new current version of
the same draft. Repeated saving under an occupied name uses the ordinary explicit
Replace / Save as copy choice, including when that draft was saved previously.

This amends ADR 0036's file requirement for experimental definitions and ADR 0038's
single A2UI Skill boundary: `card-author` and `rich-views` have independent switches,
while Cards share vocabulary, validation, rendering and action semantics. Saving a
definition makes it available to future Turns through the existing Profile layer.

We considered registering each experiment immediately and storing draft files in a
separate directory. Keeping drafts in the event log preserves the running catalog,
retains the exact view shown to the user and avoids orphaned draft files or a second
source of truth; repeated revision payloads grow with the Chat's history instead.
11 changes: 11 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ fresh stream on demand — so conversations survive a gateway restart with compl
context, not just a text transcript. This applies to **every** surface (web and
all chat channels), keyed by chat id.

**Custom Cards.** Ask for a custom visual answer or template, then refine it in Chat.
The `card-author` Skill creates independent drafts with version history; experiments
stay in that Chat across restarts. Only the current version of each draft offers
**Save**. Choose its name and filename to save a reusable definition into the Profile's
`cards/` directory in Files. A name or filename collision requires **Replace** or
**Save as copy**; current instance values stay in Chat, separate from the saved worked
example. You can also explicitly ask the assistant to save or restore an earlier
version. Saved definitions become available on the next Turn, including in other
existing Chats. `card-author` and `rich-views` have independent switches in Settings;
turning authoring off preserves historical previews and their Save buttons.

Endpoints:

```
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ dependencies = [
"python-telegram-bot>=21.0",
"discord.py>=2.4",
"slack-bolt>=1.18",
"jsonschema>=4.26.0",
]

[project.optional-dependencies]
Expand All @@ -44,6 +45,7 @@ dev = [
"mypy>=2.3,<2.4",
"types-PyYAML>=6.0",
"pre-commit>=4.0.0",
"types-jsonschema>=4.26.0.20260518",
]
# Provider client libraries not in the base install.
# Opt-in: `pip install "ag2-assistant[ollama]"`.
Expand Down Expand Up @@ -115,7 +117,7 @@ ignore-words-list = "ans,hsi,thur,unparseable,pre-empt,pre-selects,protext,padd,
where = ["src"]

[tool.setuptools.package-data]
"assistant" = ["skills/*/SKILL.md", "cards/bundled/*.card.yaml"]
"assistant" = ["skills/*/SKILL.md", "cards/bundled/*.card.yaml", "cards/VOCABULARY.md"]
"assistant.gateway" = [
"static/*.html",
"static/*.svg",
Expand Down
13 changes: 11 additions & 2 deletions src/assistant/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

from assistant.a2ui import CardCatalog
from assistant.a2ui_skill import A2UISkillRuntime
from assistant.card_author import CardAuthorRuntime
from assistant.codex_auth import BACKEND_BASE, CodexAuth, default_headers
from assistant.coding import acp_provider
from assistant.config import Config, load_config
Expand Down Expand Up @@ -299,19 +300,27 @@ def resolve_a2ui_skill(config: Config):
return FilteredSkillRuntime(A2UISkillRuntime(CardCatalog(config)), _availability(config))


def build_skills_plugin(config: Config, runtime, *, catalog=None, snapshot: bool = False):
def build_skills_plugin(
config: Config, runtime, *, catalog=None, snapshot: bool = False, drafts=None
):
"""Build progressive disclosure and activation tools over resolved Skill availability."""
author = FilteredSkillRuntime(
CardAuthorRuntime(drafts, catalog or CardCatalog(config)),
_availability(config),
snapshot=snapshot,
)
if snapshot:
runtime.invalidate()
return SkillPlugin(
author,
FilteredSkillRuntime(
A2UISkillRuntime(catalog or CardCatalog(config)),
_availability(config),
snapshot=True,
),
FilteredSkillRuntime(runtime, _availability(config), snapshot=True),
)
return SkillPlugin(resolve_a2ui_skill(config), resolve_skills(config, runtime))
return SkillPlugin(author, resolve_a2ui_skill(config), resolve_skills(config, runtime))


def build_skills_install_tools(config: Config, runtime) -> list:
Expand Down
143 changes: 143 additions & 0 deletions src/assistant/card_author.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""The independently available Bundled Card author MemorySkill."""

import json
from collections.abc import Callable, Sequence
from dataclasses import replace
from pathlib import Path
from typing import Any

from ag2.context import ConversationContext
from ag2.tools.skills import MemoryRuntime, MemorySkill
from ag2.tools.skills.skill_types import Resource, Script, Skill, SkillMetadata

from assistant.a2ui_skill import _fields_from
from assistant.card_drafts import CardDrafts, DraftError
from assistant.cards import CardError
from assistant.cards.layout import PROPERTIES, primitive_schema

CARD_AUTHOR = "card-author"
DESCRIPTION = "Create and refine a custom interactive Card in this Chat when the user requests a custom view or no available rich view fits. Save its reusable definition only on explicit request."
INSTRUCTIONS = """Prefer a suitable available Card from rich-views before authoring a custom one.
Read vocabulary.md and examples.md before authoring. Use the existing primitives, styling
words, bindings and repeated templates; never add CSS, JavaScript or backend handlers.
Gather factual values from the user's context and available tools; leave missing facts
absent and explain gaps. For a requested template only, clearly label example values as
illustrative. Definition.example is a worked example, separate from the current data.

run_skill_script(name="card-author", script="draft_card", args={"definition": {...},
"data": {...}, "title": "..."}) validates and draws in this same Turn. The definition has
name, description, fields (JSON schemas), required, layout, example and optional topic.
Errors draw nothing: repair the actual definition/data and retry. No file is created.

To iterate, read drafts.md for the short index, then read_draft with draft_id and optional
version for the exact definition and current surface data. Target the intended draft from
conversation; ask when ambiguous. draft_card revises it with draft_id and expected_version.
Each draft is independent; revisions append to history and supersede only that draft.
restore_card takes draft_id, version (the earlier version), expected_version (current), and
optional schema-compatible data; it appends a new version. Input edits are instance changes.

Save ONLY on a clear user request: save_card takes draft_id, expected_version, surface_id,
name, filename (a safe basename ending .card.yaml), and request_id (unique to this request).
For ambiguous target/name ask first. A free name saves directly. On conflict ask Replace or
Save as copy: Replace resubmits with replace=true and the returned conflict_token; a copy
needs a distinct name and filename and a new request_id. Never infer overwrite permission
from an earlier save. Current instance data stays in Chat history. Saved definitions become
available on future Turns. Finish with brief orientation; do not restate the entire view.
"""


def card_author_descriptor() -> Skill:
"""The Bundled discovery entry used by Settings."""
return Skill(metadata=SkillMetadata(name=CARD_AUTHOR, description=DESCRIPTION))


class _CardAuthorSkill(MemorySkill):
"""The author's discoverable resources and structured scripts."""

@property
def descriptor(self) -> Skill:
return replace(
super().descriptor,
resources=tuple(
Resource(name=name) for name in ("vocabulary.md", "examples.md", "drafts.md")
),
scripts=tuple(
Script(name=name)
for name in ("draft_card", "read_draft", "restore_card", "save_card")
),
)


class CardAuthorRuntime(MemoryRuntime):
"""Progressively disclose authoring references and context-scoped operations."""

def __init__(self, drafts: CardDrafts | None, catalog) -> None:
super().__init__(
_CardAuthorSkill(
name=CARD_AUTHOR,
description=DESCRIPTION,
instructions=INSTRUCTIONS,
)
)
self.drafts = drafts
self.catalog = catalog

async def read(self, name: str, context: ConversationContext) -> str:
return f'<skill_content name="{CARD_AUTHOR}">{INSTRUCTIONS}</skill_content>'

async def read_resource(self, name: str, resource: str, context: ConversationContext) -> str:
if resource == "vocabulary.md":
reference = (Path(__file__).parent / "cards" / "VOCABULARY.md").read_text()
schemas = {kind: primitive_schema(kind) for kind in PROPERTIES}
return (
reference
+ "\n\nValidated primitive properties:\n"
+ json.dumps(schemas, ensure_ascii=False)
)
if resource == "examples.md":
return json.dumps(
[
{
"name": card.name,
"description": card.description,
"fields": card.fields,
"required": card.required,
"layout": card.layout,
"example": card.example,
}
for card in self.catalog.cards().values()
if card.name in {"Checklist", "DecisionMatrix"}
],
ensure_ascii=False,
)
if resource == "drafts.md" and self.drafts is not None:
return json.dumps(await self.drafts.index(context), ensure_ascii=False)
raise FileNotFoundError(resource)

async def execute(
self,
name: str,
script: str,
context: ConversationContext,
args: dict[str, Any] | Sequence[str] | None = None,
) -> str:
if self.drafts is None:
return "Card author requires a durable Chat history."
values = _fields_from(args)
if values is None:
return "Not applied: pass a JSON object of named arguments."
operations: dict[str, Callable] = {
"draft_card": self.drafts.draft,
"read_draft": self.drafts.read,
"restore_card": self.drafts.restore,
}
if script == "save_card":
operations[script] = self.drafts.save
operation = operations.get(script)
if operation is None:
raise FileNotFoundError(script)
try:
result = await operation(context, **values)
return json.dumps(result, ensure_ascii=False)
except (CardError, DraftError, TypeError, OSError) as exc:
return f"Not applied: {exc}"
Loading
Loading