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:
+165
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user