Imperal Docs
Recipes

Skeleton — surface count and recent items to the LLM

Live data feed that gives the LLM ambient context every chat turn — counts + previews

A skeleton is LLM-facing, not user-facing. Every time the user sends a chat message, the web-kernel injects the latest snapshot from every registered skeleton section into the intent classifier context before the user's message. The LLM never calls a skeleton tool explicitly — it reads the result as ambient awareness. The web-kernel refreshes each section automatically on the TTL schedule; your handler just needs to return fresh data.

Use skeletons for "what does the user have right now" data: pending counts, overdue items, recent activity. Keep the payload small — everything you return appears before every chat message. This pattern is drawn directly from tasks/skeleton.py.


skeleton.py — complete minimal example
from __future__ import annotations

import logging

from imperal_sdk import Extension

log = logging.getLogger(__name__)

ext = Extension(
    "tasks",
    version="1.0.0",
    display_name="Tasks",
    description="Tasks extension — manage and track your tasks with AI assistance.",
    icon="icon.svg",
    actions_explicit=True,
)

# ── Skeleton: surface pending counts + recent items ───────────────────────────
#
# TTL=30: task counters must stay fresh after chat-initiated writes.
# alert=True: the web-kernel calls skeleton_alert_tasks when the snapshot changes.
# Production ref: tasks/skeleton.py

@ext.skeleton(
    "tasks",
    alert=True,
    ttl=30,
    description="Background: today/overdue/upcoming counts + recent tasks + active projects.",
)
async def skeleton_refresh_tasks(ctx) -> dict:
    """Refresh task counters and recent activity. Idempotent — safe per tick."""
    uid = ctx.user.imperal_id
    if not uid:
        return {"response": {"note": "no user on context"}}

    # Fetch counts and recent items via ctx.http to the extension backend.
    # Replace the URL and params with your own API contract.
    overdue_resp = await ctx.http.get(
        "http://task-service/v1/tasks",
        params={"user_id": uid, "filter": "overdue", "per_page": 200},
    )
    today_resp = await ctx.http.get(
        "http://task-service/v1/tasks",
        params={"user_id": uid, "filter": "due_today", "per_page": 200},
    )
    recent_resp = await ctx.http.get(
        "http://task-service/v1/tasks",
        params={"user_id": uid, "sort_by": "-updated", "per_page": 5},
    )

    def _count(resp) -> int:
        body = resp.json() if resp.ok else {}
        items = body if isinstance(body, list) else body.get("items", [])
        return len(items) if isinstance(items, list) else 0

    def _recent(resp) -> list[dict]:
        body = resp.json() if resp.ok else {}
        items = body if isinstance(body, list) else body.get("items", [])
        if not isinstance(items, list):
            return []
        return [
            {
                "task_id": t.get("id", ""),
                "title":   t.get("title", "")[:80],
                "done":    t.get("done", False),
                "due_date": (t.get("due_date") or "")[:10],
            }
            for t in items
        ]

    return {"response": {
        "overdue_count": _count(overdue_resp),
        "today_count":   _count(today_resp),
        "recent_tasks":  _recent(recent_resp),
    }}


# ── Alert tool: detect new overdue tasks ────────────────────────────────────
#
# Called by the web-kernel when the snapshot diff shows a change.
# Return "" for no alert; return a non-empty string to inject an ambient notice.

@ext.tool(
    "skeleton_alert_tasks",
    description="Alerts: overdue tasks, due-today, sudden spike in backlog.",
)
async def skeleton_alert_tasks(
    ctx,
    old: dict | None = None,
    new: dict | None = None,
) -> dict:
    if not new:
        return {"response": ""}
    overdue     = new.get("overdue_count", 0)
    old_overdue = (old or {}).get("overdue_count", 0)
    today       = new.get("today_count", 0)
    old_today   = (old or {}).get("today_count", 0)
    alerts: list[str] = []
    if overdue > 0 and overdue > old_overdue:
        delta = overdue - old_overdue
        alerts.append(
            f"{delta} new overdue task(s) (now {overdue} total)"
            if delta < overdue
            else f"{overdue} task(s) overdue"
        )
    if today > 0 and today != old_today:
        alerts.append(f"{today} task(s) due today")
    return {"response": " · ".join(alerts)}

Walk-through

@ext.skeleton("tasks", alert=True, ttl=30). The first positional argument is the section name — it becomes the tool name skeleton_refresh_tasks and the Redis key suffix. alert=True tells the web-kernel to look for a paired @ext.tool("skeleton_alert_tasks") and call it whenever the snapshot changes. ttl=30 keeps the LLM's view of task counters current after chat writes that mutate task state — without a short TTL the snapshot could be stale for up to ttl seconds after a write.

Return shape {"response": {...}}. The outer "response" wrapper is required — the web-kernel's skeleton_save_section activity unwraps it before storing. The inner dict must contain flat scalar values and short lists. Every field you put here will appear before the user's message on every chat turn. Prefer counts and flags; expose full item detail via @chat.function on demand.

ctx.user.imperal_id guard. The skeleton tick runs per-user, but in edge cases (e.g. a user with no active session) the user context may be empty. Return a minimal response rather than raising — the web-kernel treats any exception as a refresh failure and retries on the next tick.

Using ctx.http for data fetch. Call your extension backend via ctx.http.get(...). ctx.http is per-request and has no shared state between users. For data already stored in ctx.store, call ctx.store.query(...) directly without HTTP.

Alert tool pairing. The alert tool receives the previous snapshot as old and the fresh snapshot as new. Return {"response": ""} for no alert; return a non-empty string to surface an ambient notification to the LLM. The alert tool only runs when the snapshot differs — idle ticks with identical data do not trigger it.

Payload size. Every skeleton section is injected before the user's message on every chat turn. A 5 KB payload costs roughly 1,250 tokens per turn. Keep recent_tasks to 5 items max; do not embed full message bodies or long descriptions.

ctx.skeleton is guarded

ctx.skeleton.get() is only available inside @ext.skeleton-decorated handlers. Validator rule V24 (AST scan) flags any ctx.skeleton access in @chat.function or @ext.panel handlers at validate time. Use ctx.cache or ctx.store in those contexts instead.


On this page