API Clients + structured JSON results: app-level tokens for Synap/WSIT integration

- New api_clients + api_client_scopes tables; tokens scoped per-instance
- Admin UI tab at /admin for token create/rotate/revoke/delete with one-time reveal
- Dual-auth dependency (user session OR Bearer app token) on trigger + runs endpoints
- /api/instances/{id}/trigger pre-creates a run and returns run_id + cached last_result instantly
- New GET /api/runs/{id} for polling
- Generic trigger path for sub-agent instances (weather, calendar, etc.)
- runs.result column for structured JSON alongside markdown output
- agent_catalog.result_schema describes each agent's result shape
- Weather, daily-briefing, project-monitor retrofitted to emit structured results
- log_run: env INSTANCE_ID/RUN_ID only used when target matches, so nested sub-agents don't clobber parent runs
- Wiki docs: API Clients & Token Scoping + Calling Agents From Your Apps
This commit is contained in:
Eric Jungbauer
2026-04-20 17:54:32 +00:00
parent f01553c511
commit 043aa18f3f
8 changed files with 983 additions and 111 deletions
+165 -1
View File
@@ -65,6 +65,7 @@ tr:hover td{background:var(--surface2)}
<div class="tab" onclick="switchTab('catalog')">Agent Catalog</div>
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
<div class="tab" onclick="switchTab('bridges')">Bridges</div>
<div class="tab" onclick="switchTab('api-clients')">API Clients</div>
<div class="tab" onclick="switchTab('system')">System</div>
</div>
@@ -136,6 +137,48 @@ tr:hover td{background:var(--surface2)}
<table id="bridges-table"><thead><tr><th>User</th><th>Hostname</th><th>URL</th><th>Platform</th><th>Status</th><th>Last Heartbeat</th><th>Capabilities</th></tr></thead><tbody></tbody></table>
</div>
<!-- API Clients -->
<div class="panel" id="panel-api-clients">
<div class="form-card">
<h3>Create API Client</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
Issues a bearer token that an external app (Synap, WSIT, etc.) can use to trigger agent instances and read results.
Tokens are scoped to specific instances — see the
<a href="https://wiki.jfamily.io/doc/api-clients-token-scoping-KhtWinIzMT" target="_blank" style="color:var(--accent)">token scoping doc</a>.
</p>
<div class="form-row">
<div class="field"><label>Name</label><input id="nac-name" placeholder="Synap"></div>
<div class="field"><label>Description</label><input id="nac-desc" placeholder="What this app uses the token for"></div>
</div>
<div class="form-row full">
<div class="field">
<label>Authorized Instances</label>
<div id="nac-instances" style="max-height:200px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.5rem"></div>
</div>
</div>
<button class="btn btn-primary" onclick="createApiClient()">Create</button>
<span class="msg" id="nac-msg"></span>
</div>
<!-- Token reveal modal (shown once after create/rotate) -->
<div id="token-reveal" class="form-card" style="display:none;border-color:var(--yellow);background:rgba(253,203,110,.05)">
<h3 style="color:var(--yellow)">New token — copy it now</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
This is the only time you will see this token. Copy it into the app's environment now. If you lose it, rotate the client to issue a new one.
</p>
<div style="display:flex;gap:.5rem;align-items:center">
<code id="token-reveal-value" style="flex:1;padding:.6rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;overflow-x:auto;white-space:nowrap"></code>
<button class="btn btn-primary btn-sm" onclick="copyToken()">Copy</button>
<button class="btn btn-sm small-btn" onclick="document.getElementById('token-reveal').style.display='none'">Dismiss</button>
</div>
</div>
<table id="api-clients-table">
<thead><tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- System -->
<div class="panel" id="panel-system">
<div class="stat-grid" id="sys-stats"></div>
@@ -232,6 +275,127 @@ async function loadBridges(){
</tr>`).join('');
}
// --- API Clients ---
let _allInstances=[]; // cache
let _lastToken=''; // for copy button
async function loadAllInstancesForPicker(selected){
selected=selected||new Set();
const res=await fetch(API+'/api/admin/instances');
if(!res.ok)return;
_allInstances=await res.json();
const container=document.getElementById('nac-instances');
if(!_allInstances.length){container.innerHTML='<div style="color:var(--text-dim);font-size:.85rem">No instances exist yet — create one first.</div>';return}
// Group by user
const byUser={};
_allInstances.forEach(i=>{(byUser[i.username]=byUser[i.username]||[]).push(i)});
container.innerHTML=Object.keys(byUser).sort().map(un=>`
<div style="margin-bottom:.5rem"><div style="font-size:.75rem;color:var(--text-dim);text-transform:uppercase;margin-bottom:.25rem">${un}</div>
${byUser[un].map(i=>`<label style="display:flex;align-items:center;gap:.5rem;padding:.25rem 0;font-size:.85rem;cursor:pointer">
<input type="checkbox" value="${i.id}" class="nac-inst-cb" ${selected.has(i.id)?'checked':''}>
<span><code style="color:var(--accent)">${i.catalog_id}</code> · ${i.name} <span style="color:var(--text-dim)">(id ${i.id})</span></span>
</label>`).join('')}</div>`).join('');
}
async function loadApiClients(){
const res=await fetch(API+'/api/admin/api-clients');
if(!res.ok)return;
const clients=await res.json();
const tbody=document.querySelector('#api-clients-table tbody');
if(!clients.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No API clients yet</td></tr>';return}
tbody.innerHTML=clients.map(c=>{
const status=c.revoked?'<span class="badge admin">revoked</span>':'<span class="badge user">active</span>';
const scopeLabels=(c.instance_ids||[]).map(id=>{
const inst=_allInstances.find(i=>i.id===id);
return inst?`${inst.username}/${inst.catalog_id}(${id})`:`#${id}`;
}).join(', ')||'<span style="color:var(--red)">none</span>';
const actions=c.revoked
? `<button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="deleteApiClient(${c.id},'${c.name}')">Delete</button>`
: `<button class="btn btn-sm small-btn" onclick="editApiClient(${c.id})">Edit</button> <button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="revokeApiClient(${c.id})">Revoke</button>`;
return `<tr>
<td><strong>${c.name}</strong>${c.description?`<div style="font-size:.75rem;color:var(--text-dim)">${c.description}</div>`:''}</td>
<td><code style="font-size:.75rem">${c.token_prefix||'-'}…</code></td>
<td style="font-size:.75rem">${scopeLabels}</td>
<td style="font-size:.75rem">${c.created_at?new Date(c.created_at).toLocaleDateString():'-'}</td>
<td style="font-size:.75rem">${c.last_used_at?new Date(c.last_used_at).toLocaleString():'<span style="color:var(--text-dim)">never</span>'}</td>
<td>${status}</td>
<td><div class="action-btns">${actions}</div></td>
</tr>`;
}).join('');
}
async function createApiClient(){
const name=document.getElementById('nac-name').value.trim();
const desc=document.getElementById('nac-desc').value.trim();
const instance_ids=[...document.querySelectorAll('.nac-inst-cb:checked')].map(cb=>parseInt(cb.value));
if(!name){showMsg('nac-msg','Name is required',false);return}
if(!instance_ids.length){showMsg('nac-msg','Pick at least one instance to scope the token',false);return}
const res=await fetch(API+'/api/admin/api-clients',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,description:desc,instance_ids})});
if(res.ok){
const c=await res.json();
revealToken(c.token);
showMsg('nac-msg','Created',true);
document.getElementById('nac-name').value='';
document.getElementById('nac-desc').value='';
loadAllInstancesForPicker();
loadApiClients();
} else {
const e=await res.json();showMsg('nac-msg',e.detail||'Error',false);
}
}
function revealToken(token){
_lastToken=token;
document.getElementById('token-reveal-value').textContent=token;
document.getElementById('token-reveal').style.display='block';
document.getElementById('token-reveal').scrollIntoView({behavior:'smooth',block:'center'});
}
function copyToken(){
navigator.clipboard.writeText(_lastToken).then(()=>{
const btn=event.target;const orig=btn.textContent;btn.textContent='Copied!';setTimeout(()=>btn.textContent=orig,1500);
});
}
async function revokeApiClient(id){
if(!confirm('Revoke this token? The app using it will stop working immediately.'))return;
await fetch(API+'/api/admin/api-clients/'+id+'/revoke',{method:'POST'});
loadApiClients();
}
async function rotateApiClient(id){
if(!confirm('Issue a new token? The old one will stop working immediately.'))return;
const res=await fetch(API+'/api/admin/api-clients/'+id+'/rotate',{method:'POST'});
if(res.ok){
const c=await res.json();
revealToken(c.token);
loadApiClients();
}
}
async function deleteApiClient(id,name){
if(!confirm('Permanently delete client '+name+'?'))return;
await fetch(API+'/api/admin/api-clients/'+id,{method:'DELETE'});
loadApiClients();
}
async function editApiClient(id){
const res=await fetch(API+'/api/admin/api-clients');
const clients=await res.json();
const c=clients.find(x=>x.id===id);
if(!c)return;
const existing=new Set(c.instance_ids||[]);
const checkboxes=[..._allInstances].map(i=>`${i.id}: ${i.username}/${i.catalog_id} (${i.name})${existing.has(i.id)?' [currently authorized]':''}`).join('\n');
const input=prompt(`Edit scopes for "${c.name}"\n\nEnter comma-separated instance IDs this token should be allowed to access.\n\nAvailable instances:\n${checkboxes}`, [...existing].join(','));
if(input===null)return;
const ids=input.split(',').map(s=>parseInt(s.trim())).filter(n=>!isNaN(n));
const res2=await fetch(API+'/api/admin/api-clients/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({instance_ids:ids})});
if(res2.ok)loadApiClients();
else{const e=await res2.json();alert(e.detail||'Error')}
}
// --- System ---
async function loadSystem(){
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
@@ -244,7 +408,7 @@ async function loadSystem(){
}
// Init
loadUsers();loadCatalog();loadProviders();loadBridges();loadSystem();
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadSystem();
</script>
</body>
</html>