Skip to content
Open
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
81 changes: 81 additions & 0 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2215,6 +2215,87 @@ async def _fire_session(
)
return True

async def handle_living_ui_app_trigger(
self,
living_ui_id: str,
trigger_name: str,
message: str,
) -> Dict:
"""Deliver an app-fired Living UI trigger into the project's chat flow.

Deterministic routing (no LLM): if an active session is already bound
to this Living UI, the trigger message feeds that session — the app
event continues the ongoing conversation about the app. Otherwise a
new session opens with source LIVING_UI_ACTION, deduped per
(project, trigger) so re-fires can't stack sessions while one is
still in flight. The manifest validation and rate limiting happened
upstream (browser adapter funnel); by this point the message is
trusted, composed content.
"""
from app.triggers import living_ui_action_dedup_key

content = f"{self._build_living_ui_prefix(living_ui_id)}\n{message}"
platform = "Living UI"

# An existing session bound to this project (its creation/dev task, or
# a chat session opened about it) keeps the conversation in one place.
try:
for trig in await self.triggers.list_triggers():
payload = getattr(trig, "payload", None) or {}
if living_ui_id in (
payload.get("living_ui_id"),
payload.get("project_id"),
):
if await self._fire_session(
trig.session_id, content, platform, living_ui_id
):
logger.info(
f"[LIVING_UI:TRIGGER] '{trigger_name}' fed existing "
f"session {trig.session_id}"
)
return {"routed": "existing", "session_id": trig.session_id}
except Exception as e:
logger.warning(
f"[LIVING_UI:TRIGGER] Session scan failed, opening new session: {e}"
)

# No bound session — open a new one. Mirrors _create_new_session_trigger
# minus the user-message specifics (no parked row: emit() is already
# durable, and dedup covers the in-flight window).
await self.state_manager.start_session(None)
self.event_stream_manager.get_main_stream().log(
"living ui app trigger",
content,
event_type=EventType.USER_MESSAGE,
display_message=content,
platform=platform,
)
self.state_manager._append_to_conversation_history("user", content)
self.state_manager.bump_event_stream()

result = await self.trigger_service.emit(
TriggerSpec(
source=TriggerSource.LIVING_UI_ACTION,
description=content,
priority=10,
session_id=await self._generate_unique_session_id(),
payload={
"living_ui_id": living_ui_id,
"trigger_name": trigger_name,
"user_message": content,
"platform": platform,
},
dedup_key=living_ui_action_dedup_key(living_ui_id, trigger_name),
)
)
if result.deduped:
logger.info(
f"[LIVING_UI:TRIGGER] '{trigger_name}' deduped — previous firing "
"still in flight"
)
return {"routed": "deduped"}
return {"routed": "new"}

async def _create_new_session_trigger(
self,
chat_content: str,
Expand Down
14 changes: 14 additions & 0 deletions app/data/living_ui_template/LIVING_UI.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,20 @@ User Action → Frontend Component → AppController → Backend API → SQLite
Update UI State
```

## Agent Triggers (config/triggers.json)

<!-- Agent: if this app fires triggers at CraftBot, list them here -->

Declared triggers let the app itself ask CraftBot to do something — a button
in the UI (`fireCraftBotTrigger(name, params)` from `frontend/agent/hooks.ts`)
or backend logic (`integration.fire_trigger(name, params)` from
`services/integration_client.py`). Only triggers declared in
`config/triggers.json` are accepted. Test with `livingui {{PROJECT_ID}} trigger <name>`.

| Trigger | Fired when | What CraftBot does |
|---------|------------|--------------------|
| (none declared) | | |

## Testing

<!-- Agent: How to verify the app works -->
Expand Down
29 changes: 29 additions & 0 deletions app/data/living_ui_template/backend/services/integration_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,35 @@ async def request(
except Exception as e:
return {"error": str(e)}

async def fire_trigger(
self, trigger: str, params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Fire one of this app's declared triggers at the CraftBot agent.

Use this from business logic — a threshold crossed, a record created,
a scheduled check failed — to have CraftBot act on it. The trigger
must be declared in config/triggers.json; `params` are validated
against its declared param spec.

Returns:
{"status": "ok", "routed": ...} on success
{"error": "..."} when rejected (undeclared trigger, bad params,
cooldown) or when no CraftBot host is available.
"""
if not self.available:
return {"error": "Integration bridge not available"}
try:
client = self._ensure_client()
r = await client.post(
f"{BRIDGE_URL}/api/bridge/trigger",
headers=self._auth_headers(),
json={"trigger": trigger, "params": params or {}},
)
return r.json()
except Exception as e:
return {"error": str(e)}

async def close(self):
"""Close the HTTP client."""
if self._client:
Expand Down
4 changes: 4 additions & 0 deletions app/data/living_ui_template/config/triggers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$comment": "Declared triggers — events this app can fire AT the CraftBot agent (the reverse of operations.json). Each trigger's 'instruction' is what the agent is asked to do when it fires; runtime 'params' are validated data, never instructions. Fire from the frontend with fireCraftBotTrigger(name, params) (frontend/agent/hooks.ts), from the backend with integration.fire_trigger(name, params) (services/integration_client.py), or test with `livingui <project> trigger <name>`. Only declared triggers are accepted. Optional per-trigger 'cooldown_seconds' raises the anti-loop floor. See the living-ui-creator skill's TRIGGERS.md reference for the full spec.",
"triggers": {}
}
24 changes: 24 additions & 0 deletions app/data/living_ui_template/frontend/agent/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,27 @@ export function useAgentCommand(handler: (command: AgentCommand) => void): void
return () => window.removeEventListener('message', onMessage)
}, [])
}

/**
* Fire one of this app's declared triggers at the CraftBot agent — the
* reverse of useAgentCommand. The trigger must be declared in
* config/triggers.json; undeclared names are rejected by the host.
* `params` are data for the trigger's declared instruction, validated
* against its param spec.
*
* Returns false when no CraftBot host is present (app opened standalone) —
* the app should degrade gracefully, this is fire-and-forget either way.
*
* @example
* <button onClick={() => fireCraftBotTrigger('restock_needed', { item_id: item.id })}>
* Ask CraftBot to restock
* </button>
*/
export function fireCraftBotTrigger(trigger: string, params?: Record<string, unknown>): boolean {
if (window.parent === window) return false
window.parent.postMessage(
{ type: 'craftbot-agent-trigger', trigger, params: params || {} },
'*',
)
return true
}
72 changes: 70 additions & 2 deletions app/living_ui/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
sys.path.insert(0, str(_REPO_ROOT))

from app.living_ui import data_plane, migration, operations # noqa: E402
from app.living_ui import triggers as trigger_plane # noqa: E402

PROG = "livingui"

Expand Down Expand Up @@ -325,6 +326,7 @@ def _drift_warning(project: Project, db_schema) -> str:
select|count|insert|update|delete|sql data (direct DB, works when stopped)
api <METHOD> <path> call an HTTP endpoint
run <op> fire a declared operation
triggers | trigger <name> [--set k=v] app→agent triggers (config/triggers.json)
ops-sync [--write] | ops-check generate/validate operations.json
migrate apply additive schema migration
start | stop | restart lifecycle (via CraftBot)
Expand Down Expand Up @@ -391,6 +393,19 @@ def project_help(project: Project) -> int:
lines.append(
"OPERATIONS: none declared (config/operations.json missing or empty)"
)

try:
declared_triggers = trigger_plane.load_triggers(project)
except trigger_plane.TriggerError as e:
declared_triggers = {}
lines.append(f"(triggers manifest broken: {e})")
if declared_triggers:
lines.append("")
lines.append(f"TRIGGERS — app→agent ({PROG} {project.slug} trigger <name>)")
for name, trig_def in list(declared_triggers.items())[:10]:
description = (trig_def.get("description") or "")[:64]
lines.append(f" {name:<24} {description}")

lines.append("")
lines.append(PROJECT_COMMANDS_HELP)

Expand Down Expand Up @@ -1080,6 +1095,52 @@ def cmd_ui(project, args):
return 0


def cmd_triggers(project, _args):
"""List the app's declared triggers (config/triggers.json)."""
try:
declared = trigger_plane.load_triggers(project)
except trigger_plane.TriggerError as e:
fail(str(e))
if not declared:
print("(no triggers declared — config/triggers.json missing or empty)")
return 0
print(f"TRIGGERS ({PROG} {project.slug} trigger <name> [--set k=v])")
for name, trig_def in declared.items():
description = (trig_def.get("description") or "")[:64]
print(f" {name:<24} {description}")
params = trig_def.get("params") or {}
if params:
print(f" {'':<24} params: {', '.join(sorted(params.keys()))}")
return 0


def cmd_trigger(project, args):
"""Fire a declared trigger at the CraftBot agent (as the app would)."""
if args.data:
try:
params = json.loads(args.data)
except Exception as e:
fail(f"--data must be a JSON object ({e})")
if not isinstance(params, dict):
fail("--data must be a JSON object")
else:
params = parse_set(args.set)
ok, response = control_call(
"trigger",
project.id,
payload={"trigger": args.name, "params": params, "origin": "cli"},
timeout=30,
)
if not ok or response.get("error"):
fail(
response.get("error", "control call failed"),
f"{PROG} {project.slug} triggers (see what's declared)",
)
routed = response.get("routed", "?")
print(f"fired '{args.name}' → agent ({routed} session)")
return 0


def cmd_snapshot(project, _args):
status, text = _http(project, "GET", "/api/ui-snapshot", timeout=10)
print(text[:4000])
Expand Down Expand Up @@ -1182,17 +1243,24 @@ def _make_parsers():
p.add_argument("--data", required=True, help='JSON command, e.g. \'{"type": "refresh"}\'')
parsers["ui"] = (p, cmd_ui)

p = argparse.ArgumentParser(prog=f"{PROG} <project> trigger")
p.add_argument("name", help="a trigger declared in config/triggers.json")
p.add_argument("--set", action="append", help="param: --set k=v (repeatable; k:=json for raw JSON)")
p.add_argument("--data", help="all params as one JSON object")
parsers["trigger"] = (p, cmd_trigger)

p = argparse.ArgumentParser(prog=f"{PROG} <project> ops-sync")
p.add_argument("--write", action="store_true", help="merge generated skeletons into operations.json")
parsers["ops-sync"] = (p, cmd_ops_sync)

p = argparse.ArgumentParser(prog=f"{PROG} <project> ops-check")
parsers["ops-check"] = (p, cmd_ops_check)

for simple in ("status", "jobs", "migrate", "snapshot"):
for simple in ("status", "jobs", "migrate", "snapshot", "triggers"):
p = argparse.ArgumentParser(prog=f"{PROG} <project> {simple}")
parsers[simple] = (p, {"status": cmd_status, "jobs": cmd_jobs,
"migrate": cmd_migrate, "snapshot": cmd_snapshot}[simple])
"migrate": cmd_migrate, "snapshot": cmd_snapshot,
"triggers": cmd_triggers}[simple])
return parsers


Expand Down
45 changes: 45 additions & 0 deletions app/living_ui/integration_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,18 @@ class IntegrationBridge:
def __init__(self, manager: "LivingUIManager"):
self._manager = manager
self._http_client = httpx.AsyncClient(timeout=30, follow_redirects=True)
# Set by the browser adapter: async (project_id, trigger, params,
# origin) -> dict. The adapter owns trigger validation + delivery;
# the bridge only authenticates which project is calling.
self.trigger_handler = None

def register_routes(self, app: web.Application) -> None:
"""Register integration bridge routes on the aiohttp app."""
app.router.add_get("/api/integrations/available", self._handle_available)
app.router.add_post("/api/integrations/proxy", self._handle_proxy)
app.router.add_post("/api/bridge/llm", self._handle_llm)
app.router.add_post("/api/bridge/vlm", self._handle_vlm)
app.router.add_post("/api/bridge/trigger", self._handle_trigger)
logger.info("[INTEGRATION_BRIDGE] Routes registered")

async def cleanup(self) -> None:
Expand Down Expand Up @@ -238,6 +243,46 @@ async def _handle_vlm(self, request: web.Request) -> web.Response:
logger.error(f"[INTEGRATION_BRIDGE] VLM error: {e}")
return web.json_response({"error": f"VLM error: {str(e)}"}, status=502)

async def _handle_trigger(self, request: web.Request) -> web.Response:
"""Fire one of the calling project's declared triggers at the agent.

Expected JSON body: {"trigger": "restock_needed", "params": {...}}.
The bearer token identifies the project, so an app can only fire its
OWN declared triggers — validation and rate limiting happen in the
adapter's shared funnel.
"""
project_id = self._validate_token(request)
if not project_id:
return web.json_response({"error": "Unauthorized"}, status=401)

if self.trigger_handler is None:
return web.json_response(
{"error": "Trigger channel not available"}, status=501
)

try:
data = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON body"}, status=400)

trigger = str(data.get("trigger", ""))
params = data.get("params") or {}
if not trigger:
return web.json_response(
{"error": "Missing required field: trigger"}, status=400
)

try:
result = await self.trigger_handler(
project_id, trigger, params, origin="backend"
)
except Exception as e:
logger.error(f"[INTEGRATION_BRIDGE] Trigger error: {e}")
return web.json_response({"error": f"Trigger error: {str(e)}"}, status=502)

status = 200 if "error" not in result else 400
return web.json_response(result, status=status)

# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
Expand Down
Loading