Automation Suggestions & Blueprints

Consent-first automation suggestions and reusable blueprint templates — surfaced through chat, CLI, or Python.

Automation Suggestions turn recurring requests into scheduled jobs — proposed explicitly, or offered automatically when the assistant notices the same intent recurring — but only ever scheduled when you tap Accept.

Diagram

👤 User

💡 Suggestion

✓ Accept

✕ Dismiss

✅ Scheduled Job

✕ Never Re-Offered

Two paths feed one consent gate — a manual propose() call, or an automatic observe() that fires once a request recurs.

Diagram

Two paths, one consent gate

threshold reached

💬 Manual propose

💡 Suggestion

🔁 Repeated turns

👀 observe

✓ Accept

✅ Scheduled Job

Quick Start

  1. Let the assistant offer it (feed each turn)

    Feed each completed turn’s inferred blueprint + slots. After the same intent recurs a few times, the engine offers it — you still tap Accept.

    python
    from praisonai.scheduler.suggestion_engine import SuggestionEngine
    
    engine = SuggestionEngine()
    
    # Called from your bot/gateway/CLI after each completed turn:
    sug_id = engine.observe(
        "morning-brief",
        slots={"hour": 8, "weekdays": "mon-fri"},
        principal="telegram:12345",  # optional, isolates per user
    )
    
    if sug_id:
        print(f"Offered automation as {sug_id} — waiting for user to accept.")

    Nothing runs until the user taps ✓ Accept. Below the threshold (default 3), observe() just counts and returns None.

  2. See pending suggestions

    Type /automations in chat. The bot lists each pending suggestion with its own Accept/Dismiss buttons.

    /automations
  3. Accept from chat

    Tap the ✓ Accept button on a suggestion. Exactly one scheduled job is created — nothing runs until you accept.

  4. Or create from a template

    Skip suggestions and build a job straight from a blueprint:

    /blueprint morning-brief hour=8 weekdays=mon-fri

How It Works

Two paths lead to the same result — a scheduled job — but only ever on an explicit accept.

Diagram
schedule_addBlueprintCatalogueSuggestionEngineBotUserschedule_addBlueprintCatalogueSuggestionEngineBotUser/automationspending()[suggestions]💡 list + Accept/Dismisstap ✓ Acceptresolve slots + materialiseprompt + scheduleschedule_add(…)✅ Automation scheduled
PathCommandWhat happens
Accept an existing suggestion/automations → ✓ AcceptMaterialises the suggested blueprint into one job and marks the suggestion accepted.
Create directly from a template/blueprint <name> [slot=value ...]Resolves slots against blueprint defaults and schedules the job immediately.
Auto-offered on recurrenceengine.observe(...) (called per turn by your bot/gateway)Silently counts. Once the intent recurs threshold times inside window_seconds, calls propose() once, resets the counter, and surfaces the same 💡 Accept/Dismiss card.

Auto-generating suggestions with observe()

observe() is the recurrence generator — a caller feeds every turn’s inferred intent, and the engine offers an automation once it recurs.

Diagram
ChatSuggestionStoreSuggestionEngineBot / GatewayChatSuggestionStoreSuggestionEngineBot / Gatewayobserve(…) — turn 1None (below threshold)observe(…) — turn 2None (below threshold)observe(…) — turn 3threshold met → reset counterpropose(…)suggestion idsug_id💡 offer with [✓ Accept] [✕ Dismiss]

Both entry points end in the same store and both require Accept — pick the one that matches who notices the recurrence.

Diagram

Assistant, per-turn feed

Your code already knows

Who notices the recurrence?

👀 observe

💬 propose

💡 Suggestion store

✓ Accept required

Use observe() when the assistant should notice recurrence itself (per-turn feed from a bot/gateway). Use propose() when your code already knows an automation is worth offering (manual CLI, dashboard, explicit rule).

observe() is deterministic, not ML — a per-intent trailing count keyed on (principal, blueprint_name, frozen(slots)). Two module-level constants set its defaults:

ConstantTypeValuePurpose
DEFAULT_RECURRENCE_THRESHOLDint3Times the same intent must recur before it is auto-proposed.
DEFAULT_RECURRENCE_WINDOW_SECfloat604800 (7 days)Trailing window in which occurrences count; older observations are pruned.

Parameters

ParameterTypeDefaultDescription
blueprint_namestr— (required)Blueprint the recurring intent maps to.
slotsOptional[Dict[str, Any]]NoneSlot values that identify the specific intent. Same blueprint + different slots = distinct intent. Nested lists/dicts/sets are supported (frozen internally).
deliverstr""Suggested delivery target, forwarded to propose().
reasonstr""Human-readable explanation. When omitted, a default "Noticed a recurring '<blueprint>' request (Nx); offering to automate it." is used.
principalOptional[str]NoneOwning end-user identity on a multi-user gateway. Counts are isolated per principal.
thresholdint3 (DEFAULT_RECURRENCE_THRESHOLD)Occurrences required before auto-proposing.
window_secondsfloat7 days (DEFAULT_RECURRENCE_WINDOW_SEC)Trailing window in which occurrences count.
ttl_secondsfloat3 days (DEFAULT_TTL_SEC from suggestion_store)TTL forwarded to propose().

Example

python
from praisonai.scheduler.suggestion_engine import SuggestionEngine

engine = SuggestionEngine()

# Feed one observation per completed turn.
# 1st and 2nd calls: returns None (below threshold=3).
engine.observe("morning-brief", slots={"hour": 8}, principal="telegram:alice")
engine.observe("morning-brief", slots={"hour": 8}, principal="telegram:alice")

# 3rd call: threshold met — engine.propose() runs, store returns a suggestion id.
sug_id = engine.observe(
    "morning-brief",
    slots={"hour": 8},
    principal="telegram:alice",
    reason="Alice asks for a morning brief around 8am every day.",
)

# Different slots for the same user = distinct intent. This is turn 1 of its own count.
engine.observe("morning-brief", slots={"hour": 9}, principal="telegram:alice")

# Different principal = fully separate count. Bob's first observation is turn 1.
engine.observe("morning-brief", slots={"hour": 8}, principal="telegram:bob")

Tuning

Adjust threshold and window_seconds to fit the workflow:

python
# Fire immediately on the first observation — handy for testing.
engine.observe("morning-brief", slots={"hour": 8}, threshold=1)

# Wait for stronger evidence over a longer window (30 days).
engine.observe(
    "morning-brief",
    slots={"hour": 8},
    threshold=5,
    window_seconds=3600 * 24 * 30,
)

# Nested slots are supported — the recurrence key is frozen recursively.
engine.observe(
    "morning-brief",
    slots={"weekdays": ["mon", "tue"], "opts": {"tz": "UTC"}},
)

Immediately after a fire, another observation with the same key is below-threshold again and the store dedups the identical intent, so there is no double-offer. self._observations is process-local best-effort memory — a restart merely resets counters; the durable state stays in the SuggestionStore.


Built-in Blueprints

Three blueprints ship ready to use, straight from blueprint_catalogue.py.

NameDescriptionCategoryDefault deliverKey slots
morning-briefDaily morning briefing with news and prioritiesdailytelegramhour (int, 8), minute (int, 0), weekdays (choice, mon-fri), focus (choice, general)
important-mailCheck for important emails at a regular intervalmonitoringtelegraminterval_minutes (int, 30), keywords (str, "urgent,important,deadline")
weekly-reviewEnd-of-week summary and reviewweeklytelegramhour (int, 17), minute (int, 0), weekdays (choice, fri), focus (choice, general)

Custom Blueprints (YAML)

Author your own blueprints under ~/.praisonai/blueprints/<name>/blueprint.yaml; a custom blueprint overrides a built-in of the same name.

yaml
name: standup-digest
version: "1.0.0"
description: Team standup digest
category: daily
default_deliver: telegram
prompt_template: "Summarise today's standup for {team}."
schedule_template: "cron:{minute} {hour} * * {weekdays_expression}"
slots:
  - name: hour
    type: int
    default: 9
  - name: minute
    type: int
    default: 30
  - name: weekdays
    type: choice
    default: mon-fri
    choices: [mon-fri, daily, weekends]
  - name: team
    type: str
    default: engineering

Discovery scans each <name>/blueprint.yaml subdirectory — see BlueprintCatalogue._load_from_directory for the exact rule.


Chat Commands


CLI Commands

Drive the same engine from the shell — full flags in Schedule CLI.

CommandPurpose
praisonai schedule blueprint <name>Create a job from a blueprint template
praisonai schedule blueprint-listList available blueprints (built-in + user YAML)
praisonai schedule suggestionsList pending automation suggestions
praisonai schedule suggestion-accept <id>Accept a suggestion and materialise the job
praisonai schedule suggestion-dismiss <id>Dismiss a suggestion
praisonai schedule suggestion-propose <blueprint>Manually propose a blueprint as a suggestion

observe() is a Python-level generator called from your bot/gateway; there is no CLI entry point today. Use praisonai schedule suggestion-propose <blueprint> for a one-shot manual proposal.


Python Usage

Propose and accept a suggestion programmatically.

python
from praisonai.scheduler.suggestion_engine import SuggestionEngine
from praisonai.scheduler.blueprint_catalogue import BlueprintCatalogue

engine = SuggestionEngine()

sug_id = engine.propose(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    deliver="telegram",
    reason="Detected daily morning request pattern",
)

if sug_id:
    catalogue = BlueprintCatalogue()
    bp = catalogue.get_blueprint("morning-brief")
    sug = engine.get_suggestion(sug_id)
    resolved = catalogue.resolve_slots(bp, sug.slots)
    prompt = catalogue.materialize_prompt(bp, resolved)
    schedule = catalogue.materialize_schedule(bp, resolved)
    engine.accept(sug_id)
    print(prompt, schedule)

propose() returns None when the pending cap (20) is reached or the same blueprint + slots was suggested within the 24-hour dedup window.

On a bot gateway the engine reads SessionContext.unified_user_id for you. Pass principal= yourself only from a custom script that has no session installed.

Gateway (automatic)

python
from praisonai.scheduler.suggestion_engine import SuggestionEngine

engine = SuggestionEngine()

# Primary generator: feed each completed turn's inferred intent.
# Inside an agent turn on a bot gateway — no principal= needed.
# The engine defaults it from SessionContext.unified_user_id.
sug_id = engine.observe(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    deliver="telegram",
)

# Fallback: propose directly when your code already knows to offer.
engine.propose(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    deliver="telegram",
    reason="Detected daily morning request pattern",
)

engine.pending()   # → only this user's suggestions

Custom script (explicit)

python
from praisonai.scheduler.suggestion_engine import SuggestionEngine

engine = SuggestionEngine()

session_user_id = "telegram:12345"

sug_id = engine.propose(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    deliver="telegram",
    reason="Detected daily morning request pattern",
    principal=session_user_id,
)

engine.pending(principal=session_user_id)   # → only this user's suggestions

User Interaction Flows

Flow A — Accept an existing suggestion in chat:

User → /automations
Bot  → "💡 You have 1 pending automation suggestion:"
Bot  → "💡 Detected daily morning request pattern
        morning-brief · hour=8, weekdays=mon-fri"
        [✓ Accept]  [✕ Dismiss]
User → taps ✓ Accept
Bot  → "✅ Automation scheduled. Added job 'morning-brief' (schedule cron:0 8 * * mon,tue,wed,thu,fri)"

Flow B — Create directly from a blueprint:

User → /blueprint
Bot  → "📋 Create an automation from a template:
        • morning-brief — Daily morning briefing with news and priorities
        • important-mail — Check for important emails at a regular interval
        • weekly-review — End-of-week summary and review
        Usage: /blueprint <name> [slot=value ...]
        Example: /blueprint morning-brief hour=8 weekdays=mon-fri"
User → /blueprint morning-brief hour=8 weekdays=mon-fri focus=tech
Bot  → "✅ Automation scheduled from 'morning-brief'. Added job 'morning-brief' (schedule cron:0 8 * * mon,tue,wed,thu,fri)"

Flow D — Auto-offered after a recurring request:

User → "Give me the morning brief"           (turn 1 → observe → below threshold)
User → next day: "morning brief please"      (turn 2 → observe → below threshold)
User → next day: "brief me on the morning"   (turn 3 → observe → threshold met → propose)
Bot  → "💡 Noticed a recurring 'morning-brief' request (3x); offering to automate it."
       morning-brief · hour=8
       [✓ Accept]  [✕ Dismiss]
User → taps ✓ Accept
Bot  → "✅ Automation scheduled. Added job 'morning-brief' ..."

Mapping natural-language turns → blueprint_name + slots is the caller’s job (bot/gateway), not something observe() infers by itself.

Flow C — Two users, one bot, no cross-talk (multi-tenant):

On a bot gateway you don’t call this by hand — the gateway installs a SessionContext per turn, and SuggestionEngine.propose(...) reads it automatically. The pseudo-code below shows what the store sees end-to-end.

Alice → DMs the bot: "remind me to file expenses every Friday at 5pm"
Bot   → add(Suggestion(principal="tg:alice", name="weekly-expenses", ...))
Bob   → DMs the same bot: "remind me to file expenses every Friday at 5pm"   (identical text)
Bot   → add(Suggestion(principal="tg:bob", ...))  # admitted — dedup is per-principal
Alice → /automations   → sees only her own pending suggestion
Alice → accepts        → job created with principal="tg:alice"
Bob   → accepts        → his own job created; no name collision, both coexist

Before per-principal scoping, Bob’s identical proposal was silently blocked by Alice’s dedup entry, and either user could accept or dismiss the other’s suggestion.


Multi-tenant isolation

Each end-user sees only their own suggestions once the gateway passes their resolved identity as principal=. Alice and Bob can share one gateway yet never see or mutate each other’s pending queue; a single-user deployment leaves principal=None and keeps the global pool.

Diagram

principal=’telegram:11′

principal=’telegram:22′

principal=None

Alice /automations

Alice’s pending

Bob /automations

Bob’s pending

Single-user /automations

Global pool

principal is any stable, opaque string that names the caller — a user id, an email hash, a tenant id. The store never parses or validates it; it only compares it. Leave it unset (None) for the global, single-tenant behaviour used by the CLI and single-user deployments.

A gateway handler that already knows who’s calling threads that identity through as principal:

python
from praisonaiagents.scheduler import Suggestion, SuggestionStore

store = SuggestionStore()  # ~/.praisonai/suggestions.json

# In a gateway handler that already knows who's calling:
def handle_user_message(user_id: str) -> None:
    store.add(Suggestion(
        id=f"sug_{user_id}_standup",
        blueprint_name="morning-brief",
        slots={"hour": 9, "weekdays": "mon-fri"},
        reason="Detected daily standup request",
        principal=user_id,          # isolate to this caller
    ))                              # pending cap + dedup window scoped per user_id

# Later, when the same user pulls their pending list:
def list_for(user_id: str) -> list[Suggestion]:
    return store.list_pending(principal=user_id)

Cross-owner access is rejected, not silently merged:

  • list_pending(principal="alice") never returns Bob’s suggestions.
  • accept(sug_id, principal="alice") returns False if the suggestion is Bob’s.
  • dismiss(sug_id, principal="alice") returns False if the suggestion is Bob’s.
  • The pending cap (MAX_PENDING_CAP, 20) and 24-hour dedup window are measured per principal, so one tenant can neither exhaust another’s slots nor block their identical proposal.

There is no admin bypass — a call passing principal="bob" cannot see Alice’s items even when the process runs as root. Scoping is enforced only by the principal the caller passes.

Diagram
SuggestionStoreGateway BotBobAliceSuggestionStoreGateway BotBobAliceadd(sug_1, principal=”telegram:alice”)add(sug_2, principal=”telegram:bob”)/automationslist_pending(principal=”telegram:alice”)[sug_1]“1 pending: sug_1”/automationslist_pending(principal=”telegram:bob”)[sug_2]“1 pending: sug_2”accept sug_2 (guessing Bob’s id)accept(“sug_2″, principal=”telegram:alice”)False (cross-owner refused)“Not found”

Safe by Default

Since PR #3507, Suggestion carries an optional principal owner and the store isolates list_pending / accept / dismiss per owner. See Per-User Scheduler Isolation for the full multi-tenant model. Access is still gated by CommandAccessPolicy (the automations permission is re-checked on every accept/dismiss tap).

Nothing is ever auto-created — the engine only materialises a job on an explicit accept. MAX_PENDING_CAP (20), the 24-hour dedup window, and the 3-day TTL are enforced within each principal, so one tenant cannot exhaust the cap or shadow another’s suggestion. Leaving principal=None measures them against the global pool.


Platform Coverage

The commands also degrade gracefully: if the scheduler extra isn’t installed, they reply ❌ Automations are not available (scheduler not installed).


Best Practices

Wire observe() on your gateway to generate suggestions on their own

Add one engine.observe(...) call per completed turn, passing the inferred blueprint + slots. That single line is what makes the assistant offer automations without a manual propose(). Thread principal= from SessionContext.unified_user_id so recurrence counts stay isolated per user.

python
from praisonai.scheduler.suggestion_engine import SuggestionEngine

engine = SuggestionEngine()

# After each completed turn:
engine.observe(
    "morning-brief",
    slots={"hour": 8, "weekdays": "mon-fri"},
    principal="telegram:12345",
)
Tune threshold and window_seconds to fit the workflow

Use threshold=1 for a fast signal that fires on the first observation (handy for testing). Use threshold=5, window_seconds=3600 24 30 when you want stronger evidence over a 30-day window before offering. The recurrence counter is process-local in-memory state, so a restart resets it — only the SuggestionStore is durable.

Isolate suggestions per user on multi-user gateways

For multi-user gateways, pass the resolved end-user identity as principal= when reading or mutating suggestions — one user’s pending queue is then invisible and non-mutable to another’s. See Per-User Scheduler Isolation for the full multi-tenant model.

python
from praisonaiagents.scheduler.suggestion_store import SuggestionStore

store = SuggestionStore()

# Only this user's pending suggestions
mine = store.list_pending(principal="telegram:12345")

# Cross-owner accept / dismiss are refused (returns False)
store.accept("sug_abc123", principal="telegram:12345")
store.dismiss("sug_abc123", principal="telegram:12345")

Store-level enforcement (per-principal pending cap + dedup window) means one tenant cannot exhaust the queue or shadow another’s suggestion. Leaving principal=None preserves the pre-scoping global pool for single-user deployments.

On a bot gateway, isolation is automatic — no admin-only guard needed for that reason

As of PR #3786, list_suggestions / accept_suggestion / dismiss_suggestion in _automations.py all thread SessionContext.unified_user_id as principal=, so per-user isolation happens without any wiring on your side. If you still want an admin-only gate on /automations for policy reasons — for example, limiting who may create automations at all — configure CommandAccessPolicy. That is orthogonal to per-user isolation: it gates whether a user may use automations, not whose they see. See Command Access Control and Per-User Scheduler Isolation.

Prefer accepting a suggestion over re-creating it

When a suggestion already exists for a pattern, accept it rather than running /blueprint — the dedup key latches on accept and avoids duplicate jobs.

Author custom YAML blueprints for team workflows

Drop a blueprint.yaml in ~/.praisonai/blueprints/<name>/ to codify team-specific automations. A custom blueprint overrides a built-in of the same name.

Skip empty ticks with a pre-run gate

For monitoring blueprints like important-mail, add a --pre-run gate to the resulting job so ticks with no new work spend no tokens. See the pre-run gate in the Schedule CLI.


Related