{
  "documentation_revision": 1,
  "api_revision": 54,
  "about": "Authored recipe composition metadata; operation truth remains generated Endpoints/OpenAPI and installed guide. Examples are synthetic, never live evidence.",
  "recipes": [
    {
      "id": "pet-monitor",
      "title": "Pet activity monitor",
      "summary": "A photo-based activity tally with explicit gaps, plus an offline control card.",
      "category": "run",
      "capability_ids": [
        "v1.watches",
        "v1.camera",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Timer → bounded camera snapshot → free local change filter → Clef choice → persistent watch tally → HTML card reads tally and state.",
          "The phone continues watching with the controller offline; remote model inference still needs internet. Pausing preserves the tally and does not extend expiry."
        ],
        "controller": [
          "Create the watch, persist returned watch_id, and generate a card with that resource_id.",
          "Changes to the watch definition require snapshotting the old tally, stopping it, and recreating; the new watch has a new tally."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "Public composition is a generic watch, not the native Pet Monitor. It does not inherit native camera selection, dual-model comparisons, pet history/portal, microphone classifier, camera search or feeding.",
        "Samples show visible activity only. A missed pet is unknown coverage; cached classifications are reused evidence, not new model observations."
      ],
      "lifecycle": {
        "start": "POST /v1/watches with the start example; retain the returned watch_id. Then render/post the card with resource_id.",
        "update": "GET state/tally first. To change the definition, export required history, DELETE old watch, POST revised definition. No watch PATCH exists.",
        "pause": "POST /v1/watches/{id}/pause with {\"paused\":true}; resume with false. Confirm user_paused from the returned metadata. Expiry is unchanged.",
        "stop": "DELETE /v1/watches/{id}; confirm deleted equals the saved id and watch.ended reason deleted. Archive needed tally BEFORE deletion. Cancel the interaction separately."
      },
      "failure": [
        "Camera locked/busy: record an error gap and retry at a later slot; never classify it as resting.",
        "Budget/quota/model failure: inspect recent_checks and watch.ended. The template never turns an unavailable reading into zero episodes."
      ],
      "cost": [
        "At most 20 paid calls/hour for this watch, plus shared phone/team limits; reused answers are free. These are model costs covered only within the current published budget.",
        "Check usage.paid_cost_usd and skipped_over_paid_cap; a quiet scene still refreshes within 300 seconds."
      ],
      "extensions": [
        "Use an enrolled lan:<id> camera after grant checks.",
        "Add a seen condition using a separate pet-present noul question to count watched time only while visible.",
        "Tune episode gaps and confidence with labeled samples before interpreting totals."
      ],
      "examples": [
        {
          "id": "start",
          "request": {
            "method": "POST",
            "path": "/v1/watches",
            "body": {
              "label": "Pet activity samples",
              "data": [
                "camera"
              ],
              "camera": "rear",
              "questions": {
                "activity": {
                  "type": "choice",
                  "instructions": "Classify only the visible animal in this photo. If absent or ambiguous choose unclear. Do not infer identity or health.",
                  "criteria": {
                    "resting": "An animal is lying still.",
                    "eating": "An animal visibly eats at a bowl.",
                    "drinking": "An animal visibly drinks at a bowl.",
                    "other": "Visible activity fits none of those.",
                    "unclear": "Absent, occluded or uncertain."
                  }
                }
              },
              "model": "clef",
              "interval_s": 30,
              "window": 1,
              "min_readings": 1,
              "expires_in_s": 7200,
              "repeat": true,
              "local": {
                "change_check": true,
                "sensitivity": "high",
                "learn_noise": true,
                "max_reuse_s": 300
              },
              "max_paid_per_hour": 20,
              "tally": {
                "track": "activity",
                "labels": [
                  "resting",
                  "eating",
                  "drinking",
                  "other"
                ],
                "min_confidence": 0.7,
                "gap_s": 90,
                "keep_days": 7
              }
            }
          },
          "response": {
            "status": 201,
            "body": {
              "watch_id": "w0123456789ab",
              "label": "Pet activity samples",
              "user_paused": false,
              "created_at": "2026-11-04T10:00:00Z",
              "expires_at": "2026-11-04T12:00:00Z",
              "interval_s": 30,
              "data": [
                "camera"
              ],
              "checks": 0,
              "matches": 0,
              "last_match_at": null,
              "readings_held": 0,
              "last_check": null,
              "usage": {
                "model": "clef",
                "answered_locally": 0,
                "paid_calls": 0,
                "paid_cost_usd": 0,
                "paid_calls_last_hour": 0,
                "max_paid_per_hour": 20,
                "skipped_over_paid_cap": 0,
                "change_check": true
              },
              "spec": {
                "label": "Pet activity samples",
                "data": [
                  "camera"
                ],
                "camera": "rear",
                "questions": {
                  "activity": {
                    "type": "choice",
                    "instructions": "Classify only the visible animal in this photo. If absent or ambiguous choose unclear. Do not infer identity or health.",
                    "criteria": {
                      "resting": "An animal is lying still.",
                      "eating": "An animal visibly eats at a bowl.",
                      "drinking": "An animal visibly drinks at a bowl.",
                      "other": "Visible activity fits none of those.",
                      "unclear": "Absent, occluded or uncertain."
                    }
                  }
                },
                "model": "clef",
                "interval_s": 30,
                "window": 1,
                "min_readings": 1,
                "expires_in_s": 7200,
                "repeat": true,
                "local": {
                  "change_check": true,
                  "sensitivity": "high",
                  "learn_noise": true,
                  "max_reuse_s": 300
                },
                "max_paid_per_hour": 20,
                "tally": {
                  "track": "activity",
                  "labels": [
                    "resting",
                    "eating",
                    "drinking",
                    "other"
                  ],
                  "min_confidence": 0.7,
                  "gap_s": 90,
                  "keep_days": 7
                }
              },
              "callback_secret": null,
              "first_check_in_s": 0,
              "delivery": "watch.matched and watch.ended go to your registered callback if you have one, and to GET /v1/events (both need the interactions permission)"
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        },
        {
          "id": "stop",
          "request": {
            "method": "DELETE",
            "path": "/v1/watches/w0123456789ab"
          },
          "response": {
            "status": 200,
            "body": {
              "deleted": "w0123456789ab"
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "watch",
        "refresh_label": "Read activity history",
        "read_suffix": "/tally",
        "freshness_seconds": 120,
        "empty": "No visible activity samples yet. Gaps and absence are unknown coverage.",
        "owner_intro": "A little window into your pet’s day.",
        "owner_note": "Sampled observations, not continuous coverage. Gaps and absence are unknown."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/dog/DogMonitor.kt",
        "app/src/main/java/cc/bridgit/app/dog/DogChecks.kt",
        "app/src/main/java/cc/bridgit/app/dog/DogAudio.kt",
        "app/src/main/java/cc/bridgit/app/dog/DogCamera.kt",
        "app/src/main/java/cc/bridgit/app/dog/PetCameraSearch.kt",
        "app/src/main/java/cc/bridgit/app/watch/WatchModule.kt",
        "app/src/main/java/cc/bridgit/app/watch/WatchTally.kt",
        "app/src/main/java/cc/bridgit/app/remoteview/RemoteViewDog.kt",
        "app/src/main/java/cc/bridgit/app/remoteview/RemoteView.kt"
      ],
      "url": "https://bridgit.cc/recipes/pet-monitor/",
      "card_url": "https://bridgit.cc/recipes/pet-monitor/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "Pet activity monitor",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"watch\",\"refresh_label\":\"Read activity history\",\"read_suffix\":\"/tally\",\"freshness_seconds\":120,\"empty\":\"No visible activity samples yet. Gaps and absence are unknown coverage.\",\"owner_intro\":\"A little window into your pet’s day.\",\"owner_note\":\"Sampled observations, not continuous coverage. Gaps and absence are unknown.\",\"id\":\"pet-monitor\",\"title\":\"Pet activity monitor\",\"summary\":\"A little window into your pet’s day.\",\"boundary\":\"Sampled observations, not continuous coverage. Gaps and absence are unknown.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "POST",
          "path": "/v1/watches",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "DELETE",
          "path": "/v1/watches/{id}",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "GET",
          "path": "/v1/watches/{id}/tally",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/watches/{id}/pause",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    },
    {
      "id": "receipt-scanner",
      "title": "Receipt scanner",
      "summary": "Read a selected receipt locally and ask the owner to confirm extracted text.",
      "category": "run",
      "capability_ids": [
        "v1.ocr",
        "v1.gallery",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Owner-selected gallery image → on-device Latin OCR → controller validates totals/date/currency → HTML confirmation.",
          "OCR is local. The card can run OCR through Bridgit.act while the controller is offline, but categorizing, deduplicating and storing a ledger need the controller."
        ],
        "controller": [
          "Select the authorized gallery_id, populate the card configuration, save only an owner-confirmed answer.",
          "Controller owns durable receipt storage and deduplication; respond acknowledges a request, not a saved ledger entry."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "OCR text is not a verified accounting record. This recipe does not pay, submit expenses, or infer a currency.",
        "gallery_id 42 is a synthetic fixture: substitute the image ID returned by the owner-authorized gallery listing. No camera capture occurs on opening the template."
      ],
      "lifecycle": {
        "start": "Configure card data, POST the generated HTML interaction; this does not automatically capture or poll.",
        "update": "Owner explicitly refreshes; controller stores approved data and posts a replacement card as needed, cancelling old pending cards.",
        "pause": "The Pause button suspends this card’s controls and suppresses results from a pending refresh. There is no background job in this starter; an in-flight capture may finish. Controller integrations must separately pause their jobs.",
        "stop": "The Stop button stops this card, suppresses late results, and requests controller shutdown through one response. Confirm/close the controller job and DELETE the interaction separately. Local stop is not proof that an external process stopped."
      },
      "failure": [
        "No image/grant/model access: show error, retain previous draft with its timestamp.",
        "Blurred/empty text: request another owner-selected image; never invent a missing amount."
      ],
      "cost": [
        "OCR has no paid remote inference. An optional controller model has its own costs and privacy boundary."
      ],
      "extensions": [
        "Add barcode recognition to match products.",
        "Capture a receipt with POST /v1/camera/snapshot, then pass its returned data_base64 to /v1/ocr."
      ],
      "examples": [
        {
          "id": "read",
          "request": {
            "method": "POST",
            "path": "/v1/ocr",
            "body": {
              "gallery_id": 42
            }
          },
          "response": {
            "status": 200,
            "body": {
              "text": "CORNER SHOP\n2026-11-04\nTOTAL 12.50 EUR",
              "script": "Latin",
              "on_device": true
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "ocr",
        "refresh_label": "Read selected receipt",
        "gallery_id": null,
        "freshness_seconds": 300,
        "empty": "Select an authorized receipt image before reading.",
        "owner_intro": "Turn a receipt into a draft you can check.",
        "owner_note": "Check the amount, date and currency before requesting a save."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/planned/MediaFeatures.kt"
      ],
      "url": "https://bridgit.cc/recipes/receipt-scanner/",
      "card_url": "https://bridgit.cc/recipes/receipt-scanner/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "Receipt scanner",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"ocr\",\"refresh_label\":\"Read selected receipt\",\"gallery_id\":null,\"freshness_seconds\":300,\"empty\":\"Select an authorized receipt image before reading.\",\"owner_intro\":\"Turn a receipt into a draft you can check.\",\"owner_note\":\"Check the amount, date and currency before requesting a save.\",\"id\":\"receipt-scanner\",\"title\":\"Receipt scanner\",\"summary\":\"Turn a receipt into a draft you can check.\",\"boundary\":\"Check the amount, date and currency before requesting a save.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "POST",
          "path": "/v1/ocr",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    },
    {
      "id": "voice-notebook",
      "title": "Voice notebook",
      "summary": "Record one bounded offline transcript and send a save request to the controller.",
      "category": "run",
      "capability_ids": [
        "v1.mic",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Explicit owner tap → 10-second microphone clip → installed Android on-device speech recognizer → editable notebook draft → one answer.",
          "No cloud fallback. The card can capture/transcribe without the controller, but saving the notebook requires the controller or another explicitly authorized storage integration."
        ],
        "controller": [
          "Controller persists returned transcript only after the owner answers. Do not store credentials or notebooks in example pages."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "Not an always-listening notebook. It captures only a bounded clip on a deliberate tap.",
        "On-device recognition availability depends on Android and installed language resources. Empty transcript is not silence evidence."
      ],
      "lifecycle": {
        "start": "Configure card data, POST the generated HTML interaction; this does not automatically capture or poll.",
        "update": "Owner explicitly refreshes; controller stores approved data and posts a replacement card as needed, cancelling old pending cards.",
        "pause": "The Pause button suspends this card’s controls and suppresses results from a pending refresh. There is no background job in this starter; an in-flight capture may finish. Controller integrations must separately pause their jobs.",
        "stop": "The Stop button stops this card, suppresses late results, and requests controller shutdown through one response. Confirm/close the controller job and DELETE the interaction separately. Local stop is not proof that an external process stopped."
      },
      "failure": [
        "No offline recognizer: not_supported before recording. Unsupported language or capture error: show the actual error and let the owner retry.",
        "With return_audio=false, transcription failure fails the request; there is no audio fallback to upload."
      ],
      "cost": [
        "No Jev/Clef call. Local microphone and speech consume battery; keep clips bounded."
      ],
      "extensions": [
        "Add a separate optional controller summarizer after the owner approves the transcript.",
        "Use the separate owner-enabled wake-word system for hands-free collection; that is not silently enabled by this recipe."
      ],
      "examples": [
        {
          "id": "record",
          "request": {
            "method": "POST",
            "path": "/v1/mic/record",
            "body": {
              "duration_seconds": 10,
              "transcribe": true,
              "return_audio": false,
              "language": "en-US",
              "store": []
            }
          },
          "response": {
            "status": 200,
            "body": {
              "recorded_at": "2026-11-04T10:00:00Z",
              "duration_seconds": 10,
              "format": "m4a",
              "sample_rate_hz": 44100,
              "size_bytes": 81200,
              "transcript": "Buy oats on the way home.",
              "on_device": true
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "voice",
        "refresh_label": "Record 10 seconds",
        "freshness_seconds": 300,
        "empty": "No recording yet. Tap only when ready to speak.",
        "owner_intro": "Catch a thought before it slips away.",
        "owner_note": "Records ten seconds only when you tap. Review the words before requesting a save."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/capability/Modules.kt",
        "app/src/main/java/cc/bridgit/app/planned/Transcriber.kt",
        "app/src/main/java/cc/bridgit/app/planned/TranscriptionResult.kt"
      ],
      "url": "https://bridgit.cc/recipes/voice-notebook/",
      "card_url": "https://bridgit.cc/recipes/voice-notebook/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "Voice notebook",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"voice\",\"refresh_label\":\"Record 10 seconds\",\"freshness_seconds\":300,\"empty\":\"No recording yet. Tap only when ready to speak.\",\"owner_intro\":\"Catch a thought before it slips away.\",\"owner_note\":\"Records ten seconds only when you tap. Review the words before requesting a save.\",\"id\":\"voice-notebook\",\"title\":\"Voice notebook\",\"summary\":\"Catch a thought before it slips away.\",\"boundary\":\"Records ten seconds only when you tap. Review the words before requesting a save.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "POST",
          "path": "/v1/mic/record",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    },
    {
      "id": "room-change-watcher",
      "title": "Room change watcher",
      "summary": "A free image-change trigger with an explicit interpretation boundary.",
      "category": "run",
      "capability_ids": [
        "v1.watches",
        "v1.camera",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Timer → camera → free local image comparison → watch.matched event → controller inspection or offline status card.",
          "model none makes no Jev/Clef calls and establishes a baseline before reporting changes."
        ],
        "controller": [
          "Receive events, avoid duplicate processing by event ID/sequence, persist meaningful history.",
          "Controller model interpretation and alerts are optional integrations, not features of the change filter."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "A changed image is not an intrusion, person count, danger or proof that something happened outside the frame.",
        "The latest state and recent checks are bounded; controller must persist event history it needs."
      ],
      "lifecycle": {
        "start": "POST /v1/watches with the start example; retain the returned watch_id. Then render/post the card with resource_id.",
        "update": "GET state/tally first. To change the definition, export required history, DELETE old watch, POST revised definition. No watch PATCH exists.",
        "pause": "POST /v1/watches/{id}/pause with {\"paused\":true}; resume with false. Confirm user_paused from the returned metadata. Expiry is unchanged.",
        "stop": "DELETE /v1/watches/{id}; confirm deleted equals the saved id and watch.ended reason deleted. Archive needed tally BEFORE deletion. Cancel the interaction separately."
      },
      "failure": [
        "Lights, shadows and movement can trigger changes. Tune sensitivity using known examples.",
        "Locked/busy camera is an error gap; first baseline does not count as a change."
      ],
      "cost": [
        "Free local comparison, no paid calls. Captures use battery and require camera permissions and an unlocked phone for built-in cameras."
      ],
      "extensions": [
        "Escalate only selected changes to a controller model, with a fresh capture.",
        "For a specific yes/no condition, change model to clef and set a paid-call cap; stop/recreate to update."
      ],
      "examples": [
        {
          "id": "start",
          "request": {
            "method": "POST",
            "path": "/v1/watches",
            "body": {
              "label": "Room image changes",
              "data": [
                "camera"
              ],
              "camera": "rear",
              "model": "none",
              "interval_s": 30,
              "window": 1,
              "min_readings": 1,
              "expires_in_s": 3600,
              "repeat": true,
              "cooldown_s": 120,
              "local": {
                "change_check": true,
                "sensitivity": "normal",
                "max_reuse_s": 300
              }
            }
          },
          "response": {
            "status": 201,
            "body": {
              "watch_id": "w0123456789ab",
              "label": "Room image changes",
              "user_paused": false,
              "created_at": "2026-11-04T10:00:00Z",
              "expires_at": "2026-11-04T11:00:00Z",
              "interval_s": 30,
              "data": [
                "camera"
              ],
              "checks": 0,
              "matches": 0,
              "last_match_at": null,
              "readings_held": 0,
              "last_check": null,
              "usage": {
                "model": "none",
                "answered_locally": 0,
                "paid_calls": 0,
                "paid_cost_usd": 0,
                "paid_calls_last_hour": 0,
                "max_paid_per_hour": null,
                "skipped_over_paid_cap": 0,
                "change_check": true
              },
              "spec": {
                "label": "Room image changes",
                "data": [
                  "camera"
                ],
                "camera": "rear",
                "model": "none",
                "interval_s": 30,
                "window": 1,
                "min_readings": 1,
                "expires_in_s": 3600,
                "repeat": true,
                "cooldown_s": 120,
                "local": {
                  "change_check": true,
                  "sensitivity": "normal",
                  "max_reuse_s": 300
                }
              },
              "callback_secret": null,
              "first_check_in_s": 0,
              "delivery": "watch.matched and watch.ended go to your registered callback if you have one, and to GET /v1/events (both need the interactions permission)"
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        },
        {
          "id": "stop",
          "request": {
            "method": "DELETE",
            "path": "/v1/watches/w0123456789ab"
          },
          "response": {
            "status": 200,
            "body": {
              "deleted": "w0123456789ab"
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "watch",
        "refresh_label": "Read recent checks",
        "read_suffix": "",
        "freshness_seconds": 120,
        "empty": "No baseline or checks yet. Nothing observed is different from no change.",
        "owner_intro": "See when the room’s picture changes.",
        "owner_note": "Image changes can be light or shadows. They do not identify a person or danger."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/watch/WatchRules.kt",
        "app/src/main/java/cc/bridgit/app/watch/Watches.kt"
      ],
      "url": "https://bridgit.cc/recipes/room-change-watcher/",
      "card_url": "https://bridgit.cc/recipes/room-change-watcher/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "Room change watcher",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"watch\",\"refresh_label\":\"Read recent checks\",\"read_suffix\":\"\",\"freshness_seconds\":120,\"empty\":\"No baseline or checks yet. Nothing observed is different from no change.\",\"owner_intro\":\"See when the room’s picture changes.\",\"owner_note\":\"Image changes can be light or shadows. They do not identify a person or danger.\",\"id\":\"room-change-watcher\",\"title\":\"Room change watcher\",\"summary\":\"See when the room’s picture changes.\",\"boundary\":\"Image changes can be light or shadows. They do not identify a person or danger.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "POST",
          "path": "/v1/watches",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "DELETE",
          "path": "/v1/watches/{id}",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "GET",
          "path": "/v1/watches/{id}",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/watches/{id}/pause",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    },
    {
      "id": "daily-briefing-panel",
      "title": "Daily briefing panel",
      "summary": "An owner-approved reminder and a card that reads current weather on demand.",
      "category": "run",
      "capability_ids": [
        "v1.schedule",
        "v1.weather",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Approved daily schedule → local reminder → owner opens the card → explicit weather refresh → panel renders source timestamps.",
          "An approved schedule runs without the controller while the listener is active. Android doze can delay it. Weather needs internet."
        ],
        "controller": [
          "Generate the briefing card, post the manifest, wait for owner approval, and verify next run/status.",
          "Maintain one schedule manifest per agent; merge with the existing submitted manifest rather than overwriting unrelated actions."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "The local schedule cannot template arbitrary API responses into rich HTML. A fresh multi-source digest needs controller code to gather, summarize and publish a new card.",
        "This complete starter is a reminder plus on-demand weather panel. Calendar/mail aggregation is an optional separately authorized integration."
      ],
      "lifecycle": {
        "start": "Inspect existing /v1/schedule/manifest, merge actions, submit start_request, wait for owner approval; GET /v1/schedule must show active. Post generated card separately.",
        "update": "Resubmit merged replacement manifest; new content requires approval. GET status confirms which digest is active.",
        "pause": "POST /v1/schedule/pause {\"paused\":true}; affects this agent’s entire manifest, not just the reminder. Resume with false.",
        "stop": "Remove only the reminder from a replacement manifest if other actions exist. DELETE /v1/schedule only if this agent has a dedicated recipe manifest. Cancel the card separately; keep_assets=true preserves saved files."
      },
      "failure": [
        "awaiting_owner is not running: inspect /v1/schedule until active.",
        "Weather stale=true or expired expires_at: show stale status, not fresh conditions; upstream_failed retains no invented forecast."
      ],
      "cost": [
        "No decision-model call in this starter. MET Norway weather is an external service and returns attribution."
      ],
      "extensions": [
        "Controller can combine authorized calendar and task data, with a separate timestamp per source.",
        "Send a new card and cancel the old one after replacing; do not open unlimited pending questions."
      ],
      "examples": [
        {
          "id": "pause",
          "request": {
            "method": "POST",
            "path": "/v1/schedule/pause",
            "body": {
              "paused": true
            }
          },
          "response": {
            "status": 200,
            "body": {
              "paused": true
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        },
        {
          "id": "resume",
          "request": {
            "method": "POST",
            "path": "/v1/schedule/pause",
            "body": {
              "paused": false
            }
          },
          "response": {
            "status": 200,
            "body": {
              "paused": false
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        },
        {
          "id": "stop",
          "request": {
            "method": "DELETE",
            "path": "/v1/schedule",
            "query": {
              "keep_assets": true
            }
          },
          "response": {
            "status": 200,
            "body": {
              "removed": true
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "weather",
        "refresh_label": "Refresh weather",
        "freshness_seconds": 900,
        "empty": "No forecast fetched. Weather requires internet and owner permission.",
        "owner_intro": "A quick look at the day ahead.",
        "owner_note": "Weather needs internet. The daily reminder is managed separately from this card."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/schedule/ScheduleModule.kt",
        "app/src/main/java/cc/bridgit/app/schedule/ScheduleRules.kt",
        "app/src/main/java/cc/bridgit/app/weather/WeatherModule.kt"
      ],
      "start_request": {
        "method": "POST",
        "path": "/v1/schedule",
        "body": {
          "manifest": {
            "timezone": "Europe/Warsaw",
            "actions": [
              {
                "id": "briefing-reminder",
                "when": {
                  "daily": [
                    "08:00"
                  ],
                  "count": 7
                },
                "do": {
                  "call": "POST /v1/interactions",
                  "body": {
                    "kind": "notice",
                    "title": "Daily briefing",
                    "text": "Your briefing is ready to refresh. Open the briefing card.",
                    "no_takeover": true
                  }
                }
              }
            ]
          }
        }
      },
      "url": "https://bridgit.cc/recipes/daily-briefing-panel/",
      "card_url": "https://bridgit.cc/recipes/daily-briefing-panel/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "Daily briefing panel",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"weather\",\"refresh_label\":\"Refresh weather\",\"freshness_seconds\":900,\"empty\":\"No forecast fetched. Weather requires internet and owner permission.\",\"owner_intro\":\"A quick look at the day ahead.\",\"owner_note\":\"Weather needs internet. The daily reminder is managed separately from this card.\",\"id\":\"daily-briefing-panel\",\"title\":\"Daily briefing panel\",\"summary\":\"A quick look at the day ahead.\",\"boundary\":\"Weather needs internet. The daily reminder is managed separately from this card.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "POST",
          "path": "/v1/schedule/pause",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "DELETE",
          "path": "/v1/schedule",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/schedule",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "GET",
          "path": "/v1/weather",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    },
    {
      "id": "external-device-dashboard",
      "title": "External device dashboard",
      "summary": "List only the agent’s enrolled LAN devices; keep availability honest.",
      "category": "run",
      "capability_ids": [
        "v1.lan_control",
        "v1.interactions"
      ],
      "support": {
        "source_api_revision": 54,
        "tested_contract_api_revision": 54,
        "minimum_recipe_api_revision": 54,
        "released": "unknown; compare public changes and installed health",
        "installed": "unknown",
        "ready": null,
        "requirements": "GET /v1/health, /v1/capabilities and installed /v1/guide must confirm every operation and grant. Source support alone is insufficient."
      },
      "execution": {
        "phone": [
          "Explicit refresh → phone returns enrolled device inventory without a probe → dashboard.",
          "The phone must be on the supported direct LAN for an authorized status action; the controller may connect remotely through direct or relay transport."
        ],
        "controller": [
          "Controller or owner performs credentialed verification/enrollment using supported adapters; not part of this learning example.",
          "Retain checked_at per successful status response. Stop polling on pause/stop and cancel the card."
        ],
        "server": "Only explicit decision-model/weather requests leave the phone; direct or relay transport still requires a running reachable phone."
      },
      "boundaries": [
        "Listing does not test devices: availability=not_checked is not online. Discovery is partial and unverified; enrollment is separate.",
        "No enrollment credentials are embedded. No relay switch, motor, light or other physical action is run by the template."
      ],
      "lifecycle": {
        "start": "Configure card data, POST the generated HTML interaction; this does not automatically capture or poll.",
        "update": "Owner explicitly refreshes; controller stores approved data and posts a replacement card as needed, cancelling old pending cards.",
        "pause": "The Pause button suspends this card’s controls and suppresses results from a pending refresh. There is no background job in this starter; an in-flight capture may finish. Controller integrations must separately pause their jobs.",
        "stop": "The Stop button stops this card, suppresses late results, and requests controller shutdown through one response. Confirm/close the controller job and DELETE the interaction separately. Local stop is not proof that an external process stopped."
      },
      "failure": [
        "No enrolled devices: show empty. A failed status call does not prove a device is offline.",
        "Unsupported adapter: display its actual advertised actions, do not substitute arbitrary URLs or claim universal smart-home support."
      ],
      "cost": [
        "No remote inference. LAN availability depends on phone/network and owner-approved enrollment."
      ],
      "extensions": [
        "After explicit authorization, add status for one enrolled ID using POST /v1/lan/devices/action.",
        "Use only that device’s advertised action schema; keep physical control separate from reading inventory."
      ],
      "examples": [
        {
          "id": "list-empty",
          "request": {
            "method": "GET",
            "path": "/v1/lan/devices"
          },
          "response": {
            "status": 200,
            "body": {
              "devices": [],
              "adapters": [
                {
                  "id": "shelly_gen1",
                  "actions": [
                    "status",
                    "relay_on",
                    "relay_off"
                  ],
                  "implemented": true,
                  "enrollment": "/v1/lan/devices/enroll",
                  "limitations": "Gen1 HTTP Basic or no auth; relay devices only, 1..4 channels; MAC checked before control"
                },
                {
                  "id": "openwrt_ubus",
                  "actions": [
                    "status",
                    "dhcp_leases"
                  ],
                  "implemented": true,
                  "enrollment": "/v1/lan/devices/enroll",
                  "limitations": "rpcd ubus session login; system.board and dhcp.ipv4leases ACLs; read-only, no router settings changes"
                },
                {
                  "id": "tapo_onvif",
                  "actions": [
                    "status"
                  ],
                  "implemented": true,
                  "enrollment": "/v1/lan/devices/enroll",
                  "limitations": "Tapo-style fixed ONVIF port 2020 and RTSP stream1/2; not arbitrary ONVIF cameras; capture and PTZ require lan_camera"
                }
              ],
              "camera_inventory": "/v1/lan-cameras"
            }
          },
          "evidence": "Synthetic contract fixture, not a device observation."
        }
      ],
      "card": {
        "mode": "lan",
        "refresh_label": "Refresh enrolled devices",
        "freshness_seconds": 120,
        "empty": "No devices enrolled for this agent. Listing does not probe the network.",
        "owner_intro": "Your shared devices at a glance.",
        "owner_note": "This list does not test device availability. No hardware is controlled here."
      },
      "evidence": [
        "app/src/main/java/cc/bridgit/app/api/Endpoints.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardRules.kt",
        "app/src/main/java/cc/bridgit/app/interact/CardActions.kt",
        "app/src/main/java/cc/bridgit/app/lan/LanModule.kt",
        "app/src/main/java/cc/bridgit/app/lan/LanRules.kt"
      ],
      "url": "https://bridgit.cc/recipes/external-device-dashboard/",
      "card_url": "https://bridgit.cc/recipes/external-device-dashboard/card.html",
      "card_request": {
        "method": "POST",
        "path": "/v1/interactions",
        "body": {
          "kind": "html",
          "title": "External device dashboard",
          "html": "<!doctype html>\n<html lang=\"en\">\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Bridgit recipe</title>\n<style>\n:root{--ink:#2b1834;--paper:#fff8ed;--line:#e6dbe9;--muted:#6f5f75;--accent:#704394;--lav:#efe4f7;--ok:#e2f3e8}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,sans-serif}main{max-width:440px;margin:auto;padding:22px 18px 28px}.eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700;color:var(--accent)}h1{font:600 28px/1.15 Georgia,serif;margin:7px 0 12px}.intro{color:var(--muted);font-size:14px}.panel{border:1px solid var(--line);border-radius:20px;background:white;padding:18px;margin:18px 0;box-shadow:0 8px 24px #2b183408}.pill{display:inline-block;border-radius:99px;padding:4px 10px;background:var(--lav);color:var(--accent);font-size:12px;font-weight:700}body[data-state=error] .pill{background:#fde9df;color:#a23520}body[data-state=stale] .pill{background:#fff0c7;color:#785513}h2{font-size:15px;margin:12px 0 5px}#updated,.note{color:var(--muted);font-size:12px}.tiles{display:grid;grid-template-columns:1fr 1fr;gap:10px}.tile{background:#efe4f7;border-radius:12px;padding:12px}.tile strong,.tile small{display:block}.tile strong{font-size:18px}.tile small{font-size:11px;color:var(--muted);margin-top:4px}.history{padding:0;list-style:none}.history li{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:10px 0}.history li>span:last-child{text-align:right}.metric{background:var(--paper);border-radius:12px;padding:12px;margin-top:12px;overflow-wrap:anywhere;font-size:13px}textarea{font:inherit;color:var(--ink);background:var(--paper);width:100%;min-height:120px;border:1px solid var(--line);border-radius:12px;padding:12px}.actions{display:grid;grid-template-columns:1fr 1fr;gap:9px}button{border:1px solid var(--line);border-radius:12px;background:white;color:var(--ink);font:600 14px system-ui;padding:13px 8px;cursor:pointer;min-height:46px}button.primary{grid-column:1/-1;background:var(--ink);color:white}button:disabled{opacity:.45;cursor:default}button:focus-visible,textarea:focus-visible{outline:3px solid var(--accent);outline-offset:3px}#confirmation{padding:12px;border-radius:12px;background:var(--ok);font-size:13px}footer{margin-top:18px;border-top:1px solid var(--line);padding-top:12px;font-size:12px;color:var(--muted)}\n</style></head>\n<body data-state=\"empty\"><main><p class=\"eyebrow\">Bridgit · Builder recipe</p><h1 id=\"title\"></h1><p class=\"intro\" id=\"intro\"></p>\n<section class=\"panel\" aria-live=\"polite\"><span class=\"pill\" id=\"state\">Empty</span><h2 id=\"message\"></h2><p id=\"updated\">No reading yet</p><div id=\"detail\" class=\"metric\" hidden></div><label id=\"draft-label\" hidden>Confirm or edit the draft<textarea id=\"draft\"></textarea></label></section>\n<div class=\"actions\"><button id=\"refresh\" class=\"primary\"></button><button id=\"pause\">Pause</button><button id=\"stop\">Stop</button><button id=\"answer\" class=\"primary\">Send confirmed draft</button></div><p id=\"confirmation\" role=\"status\" hidden></p>\n<footer id=\"boundary\"></footer><p class=\"note\">No automatic capture or polling. Refresh is explicit. A sent request is confirmed only after Bridgit persists the answer.</p></main>\n<script>\n// Authored visual language adapts docs/examples/pet-monitor-card.html and public/samples/action-card.html.\nvar RECIPE = /*RECIPE*/{\"mode\":\"lan\",\"refresh_label\":\"Refresh enrolled devices\",\"freshness_seconds\":120,\"empty\":\"No devices enrolled for this agent. Listing does not probe the network.\",\"owner_intro\":\"Your shared devices at a glance.\",\"owner_note\":\"This list does not test device availability. No hardware is controlled here.\",\"id\":\"external-device-dashboard\",\"title\":\"External device dashboard\",\"summary\":\"Your shared devices at a glance.\",\"boundary\":\"This list does not test device availability. No hardware is controlled here.\",\"resource_id\":null}/*END RECIPE*/;\nvar bridge = window.Bridgit || window.Bridget;\nvar busy=false, paused=false, stopped=false, revision=0, answered=false, sending=false, lastAt=null, lastBody=null;\nfunction el(id){return document.getElementById(id)}\nfunction show(state,message){document.body.dataset.state=state;el('state').textContent=state.charAt(0).toUpperCase()+state.slice(1);el('message').textContent=message;controls()}\nfunction controls(){el('refresh').disabled=busy||paused||stopped;el('pause').disabled=busy||stopped;el('stop').disabled=busy||stopped;el('answer').disabled=busy||answered||sending||stopped||paused||!el('draft').value.trim();el('pause').textContent=paused?'Resume':'Pause'}\nfunction esc(v){return String(v===null||v===undefined?'Unknown':v).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c]})}\nfunction time(v){var n=typeof v==='number'?v:Date.parse(v);return Number.isFinite(n)?new Date(n).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Time unavailable'}\nfunction tile(label,value){return '<div class=\"tile\"><strong>'+esc(value)+'</strong><small>'+esc(label)+'</small></div>'}\nfunction rows(items){return '<ul class=\"history\">'+items.map(function(i){return '<li>'+i+'</li>'}).join('')+'</ul>'}\nfunction detail(body){lastBody=body;el('detail').hidden=false;var html='',text=body.transcript===undefined?body.text:body.transcript;\nif(RECIPE.mode==='watch'&&RECIPE.read_suffix==='/tally'){\nvar today=body.today||{},labels=today.labels||{};html='<h2>Today’s observed activity</h2><div class=\"tiles\">'+tile('Observed minutes',today.watched_minutes===undefined?'—':today.watched_minutes)+tile('Readings',today.checks===undefined?'—':today.checks)+'</div>';\nhtml+=rows(Object.keys(labels).map(function(k){var v=labels[k];return '<span>'+esc(k)+'</span><span>'+esc(v.episodes)+' episodes · '+esc(v.minutes)+' min</span>'}));\nvar episodes=(body.episodes||[]).slice(-5).reverse();html+='<h2>Recent episodes</h2>'+rows(episodes.map(function(e){return '<span>'+esc(e.label)+'</span><span>'+esc(time(e.start))+' · '+esc(e.minutes)+' min</span>'}));if(!episodes.length)html+='<p>No episodes yet. Missing coverage is unknown.</p>';\n}else if(RECIPE.mode==='watch'){\nvar checks=body.recent_checks||[],last=body.last_check||{};html='<div class=\"tiles\">'+tile('Checks',body.checks===undefined?'—':body.checks)+tile('Change matches',body.matches===undefined?'—':body.matches)+'</div><h2>Recent image checks</h2>'+rows(checks.slice(-5).reverse().map(function(c){return '<span>'+esc(time(c.at))+'</span><span>'+esc(c.error?'Unavailable':c.skipped?'Skipped':c.matched?'Image changed':'No match / baseline')+'</span>'}));if(!checks.length)html+='<p>No retained checks yet.</p>';html+='<p class=\"note\">Image difference is not person or danger detection.</p>';\n}else if(RECIPE.mode==='weather'){\nvar now=body.now||{},next=body.next_24_hours||{};html='<h2>'+esc(now.summary||'Conditions unavailable')+'</h2><div class=\"tiles\">'+tile('Temperature',now.temperature_c===undefined?'—':now.temperature_c+' °C')+tile('Wind',now.wind_speed_mps===undefined?'—':now.wind_speed_mps+' m/s')+'</div><h2>Next 24 hours</h2><div class=\"tiles\">'+tile('Low / high',next.min_temperature_c===undefined||next.max_temperature_c===undefined?'—':next.min_temperature_c+' / '+next.max_temperature_c+' °C')+tile('Rain / snow',next.precipitation_mm===undefined?'—':next.precipitation_mm+' mm')+'</div><p class=\"note\">'+esc(typeof body.attribution==='string'?body.attribution:'Weather: MET Norway')+' · Forecast '+esc(time(body.forecast_updated_at))+'</p>';\n}else if(RECIPE.mode==='lan'){\nhtml='<h2>Devices shared with this agent</h2>'+rows((body.devices||[]).map(function(d){return '<span><strong>'+esc(d.name)+'</strong><br><small>'+esc(d.adapter)+'</small></span><span>'+esc(d.availability==='online'?'Verified online':d.availability==='not_checked'?'Not checked':'Availability unknown')+'<br><small>'+esc(d.checked_at?time(d.checked_at):'No current probe')+'</small></span>'}));if(!(body.devices||[]).length)html+='<p>No enrolled devices. Add one through owner-approved setup.</p>';html+='<p class=\"note\">Inventory refresh does not probe devices or move hardware.</p>';\n}else if(RECIPE.mode==='voice'){\nhtml='<div class=\"tiles\">'+tile('Bounded recording',body.duration_seconds===undefined?'—':body.duration_seconds+' seconds')+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+'</div><p>Review the transcript below before requesting a save.</p>';\n}else{html='<div class=\"tiles\">'+tile('Recognition',body.on_device===true?'On device':'Unconfirmed')+tile('Script',body.script||'Unknown')+'</div><p>Check amount, date and currency in the draft. OCR has not verified them.</p>'}\nel('detail').innerHTML=html;el('draft-label').hidden=typeof text!=='string';el('answer').hidden=typeof text!=='string';el('draft').value=typeof text==='string'?text:''}\nfunction sampleAt(body){return body.last_seen_at||body.last_check&&body.last_check.at||body.recorded_at||body.fetched_at||null}\nfunction render(body){detail(body);lastAt=sampleAt(body);el('updated').textContent=lastAt?'Source observed '+time(lastAt):'Read now; source observation time unavailable';if(body.last_check&&(body.last_check.error||body.last_check.skipped)){show('error',body.last_check.message||'Latest observation unavailable. Earlier readings are retained.');return}var empty=(RECIPE.mode==='ocr'&&(!body.text||!body.text.trim()))||(RECIPE.mode==='voice'&&(!body.transcript||!body.transcript.trim()))||Array.isArray(body.devices)&&body.devices.length===0||RECIPE.mode==='watch'&&!body.last_check&&!body.last_seen_at;var expiry=body.expires_at&&Date.parse(body.expires_at);var stale=body.stale===true||Number.isFinite(expiry)&&expiry<Date.now()||lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000;show(stale?'stale':empty?'empty':'ready',stale?'This reading needs a refresh':empty?RECIPE.empty:'Reading received. Check its source time.');controls()}\nasync function act(request){if(!bridge||typeof bridge.act!=='function')throw Error('Open this card in a compatible Bridgit build. Browser preview has no device access.');var result=await bridge.act(request);if(!result||!result.ok)throw Error(result&&result.body&&result.body.message||result&&result.error||'Action failed');return result.body}\nfunction request(){if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Controller must configure the watch_id returned by start.');return {method:'GET',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+RECIPE.read_suffix}}if(RECIPE.mode==='ocr'){if(!Number.isInteger(RECIPE.gallery_id))throw Error('Controller must configure the owner-selected gallery_id.');return {method:'POST',path:'/v1/ocr',body:{gallery_id:RECIPE.gallery_id}}}if(RECIPE.mode==='voice')return {method:'POST',path:'/v1/mic/record',body:{duration_seconds:10,transcribe:true,return_audio:false,language:'en-US',store:[]}};if(RECIPE.mode==='weather')return {method:'GET',path:'/v1/weather',query:{hours:3}};return {method:'GET',path:'/v1/lan/devices'}}\nasync function refresh(){if(busy||paused||stopped)return;busy=true;var turn=++revision;show('loading',RECIPE.mode==='voice'?'Recording one 10-second clip…':'Reading from the phone…');try{var body=await act(request());if(turn===revision&&!stopped&&!paused)render(body)}catch(e){if(turn===revision&&!stopped&&!paused)show('error',e.message)}finally{busy=false;controls()}}\nasync function pause(){if(busy||stopped)return;busy=true;var next=!paused;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('Start and configure a watch first.');var b=await act({method:'POST',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)+'/pause',body:{paused:next}});if(b.user_paused!==next)throw Error('Pause state was not confirmed by the phone.')}paused=next;show(paused?'paused':'empty',paused?'Paused. Existing readings keep their timestamps.':'Resumed. Refresh to read current state.')}catch(e){show('error',e.message)}finally{busy=false;controls()}}\nfunction send(action){if(answered||sending)return;if(!bridge||typeof bridge.respond!=='function'){show('error','Browser preview cannot send an answer.');return}sending=true;controls();el('confirmation').hidden=false;el('confirmation').textContent='Waiting for Bridgit to persist the request…';bridge.respond(JSON.stringify({recipe_id:RECIPE.id,action:action,text:action==='confirm'?el('draft').value:null,source_at:lastAt,requested_at:new Date().toISOString()}))}\nasync function stop(){if(busy||stopped)return;busy=true;revision++;try{if(RECIPE.mode==='watch'){if(!RECIPE.resource_id)throw Error('No watch_id is configured; no running watch was stopped.');var b=await act({method:'DELETE',path:'/v1/watches/'+encodeURIComponent(RECIPE.resource_id)});if(b.deleted!==RECIPE.resource_id)throw Error('The phone did not confirm this watch was deleted.');stopped=true;show('stopped','Watch deletion confirmed by the phone.')}else{stopped=true;show('stopped',answered?'Card stopped. This card already answered; contact your agent to stop any remaining job.':'Card stopped. Controller shutdown is requested separately.');send('stop')}}catch(e){show('error',e.message)}finally{busy=false;controls()}}\ndocument.addEventListener('bridgit:answered',function(){answered=true;sending=false;el('confirmation').hidden=false;el('confirmation').textContent='Request saved by Bridgit. Controller processing has not been confirmed.';controls()});\nel('refresh').onclick=refresh;el('pause').onclick=pause;el('stop').onclick=stop;el('answer').onclick=function(){send('confirm')};el('draft').oninput=controls;\nel('answer').hidden=true;el('title').textContent=RECIPE.title;el('intro').textContent=RECIPE.summary;el('boundary').textContent=RECIPE.boundary;el('refresh').textContent=RECIPE.refresh_label;show('empty',RECIPE.empty);\nsetInterval(function(){if(!busy&&!paused&&!stopped&&lastAt&&Date.now()-Date.parse(lastAt)>RECIPE.freshness_seconds*1000)show('stale','This reading needs a refresh')},15000);\n</script></body></html>\n",
          "no_takeover": true,
          "expires_in_seconds": 86400
        }
      },
      "operations": [
        {
          "method": "GET",
          "path": "/v1/lan/devices",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        },
        {
          "method": "POST",
          "path": "/v1/interactions",
          "documentation": "https://bridgit.cc/learn/openapi.json"
        }
      ]
    }
  ]
}
