Skip to content

Audit and events

SkyPortal records what SARA did in each chat and who approved each action. You can export both over the API: the event log of one chat, and the audit trail of your account or team.

Chat event export

Every chat keeps a log of its workflow events: messages, plans, approval requests and decisions, tool calls with their output, and errors.

GET /api/v1/agent/chat/{chat_id}/events/
  • Authentication: an API key or access token, sent as Authorization: Bearer <key>. A signed-in browser session doesn't work for this endpoint.
  • Access: the chat's owner, or an active member of a team the owner belongs to. Anyone else gets 404.
Parameter Description
after_timestamp ISO-8601 timestamp. Only events strictly after it are returned
event_types Comma-separated event names, for example BashCommandApprovalRequestedEvent,StopEvent
limit Default 100, maximum 500
curl -s -H "Authorization: Bearer $SKYPORTALAI_API_KEY" \
  "https://app.skyportal.ai/api/v1/agent/chat/123/events/?event_types=BashCommandApprovalRequestedEvent,StopEvent&limit=100"
{
  "events": [
    {
      "event_id": "8f1c…",
      "event_type": "BashCommandApprovalRequestedEvent",
      "timestamp": "2026-10-01T14:02:11.120000+00:00",
      "workflow_id": "5b2e…",
      "data": {
        "event_id": "8f1c…",
        "timestamp": "2026-10-01T14:02:11.120000+00:00",
        "workflow_type": "bash_commands",
        "chat_id": 123,
        "approval_id": "a1b2…",
        "command": "df -h",
        "reason": "…",
        "server_ids": [45]
      }
    }
  ],
  "has_more": true,
  "next_timestamp": "2026-10-01T14:03:40.002000+00:00"
}

Events come oldest first. To read the next page, pass next_timestamp as after_timestamp. An invalid after_timestamp returns an empty list with an error field.

data holds the event's full payload, which can include commands and their output. Treat exports as sensitive.

Useful event types

Event What it records
StartEvent A turn started: the message and the user
StopEvent A turn ended: completed, blocked, failed or cancelled, with the final response or error
PlanGeneratedEvent SARA proposed a plan
PlanApprovalRequestedEvent, PlanApprovalGrantedEvent, PlanApprovalRejectedEvent A plan approval and its decision
BashCommandApprovalRequestedEvent A command waiting for approval: the command, the reason and the hosts
BashCommandApprovalGrantedEvent, BashCommandApprovalRejectedEvent The decision, with the rejection reason
CommandAutoApprovedEvent A command that ran without a card, and why (permit list, Read-Only Mode)
ApprovalTimeoutEvent An approval that timed out
ToolCallStartedEvent, ToolCallFinishedEvent, ToolCallFailedEvent A tool call. Finished events include the exit code, the command and its output
SensitiveFileAccessRequestedEvent, SensitiveFileAccessApprovedEvent, SensitiveFileAccessRejectedEvent Access to a sensitive file and its decision
ErrorEvent An error during the turn

From the Python SDK

from skyportalai import Skyportal

with Skyportal() as client:
    cursor = None
    while True:
        page = client.chat.get_events(123, after_timestamp=cursor, limit=500)
        for event in page["events"]:
            print(event["timestamp"], event["event_type"])
        if not page["has_more"]:
            break
        cursor = page["next_timestamp"]

Chat.events() does the same for a chat handle. The SDK also exposes the chat's execution status, tool calls, reasoning steps, evaluations and environment; see Python SDK.

Audit trail

The audit trail records every approval decision and every tool call SARA makes, with who decided and through which channel. It is meant for export to a SIEM or for your own records.

GET /api/v1/audit/events/

Availability

On October 6, 2026, this endpoint still returned 404 on app.skyportal.ai. If you get a 404, the audit trail hasn't reached your deployment yet.

  • Authentication: an API key or access token, or a signed-in session.
  • Scope: without parameters, you get your personal trail. With ?team=<team id>, you get the team's trail, and only a team admin may read it.
Parameter Description
since, until ISO-8601 timestamps with a time zone
actor User ID of the person who acted
action For example approval.bash_command, approval.plan or tool.run_command
outcome For example approved, rejected, timed_out, succeeded or failed
channel web, cli_sdk, agent or system
server_id, approval_id, correlation_id Narrow to one host, approval or related group of events
after Event ID to continue from
limit Default 100, maximum 500

The response has scope, events, has_more and next_after. Events come in ID order, oldest first. Poll with after=<next_after> and you never miss or repeat an event.

{
  "scope": "team:7",
  "has_more": true,
  "next_after": 981,
  "events": [
    {
      "id": 981,
      "created_at": "2026-10-02T09:14:03.511000+00:00",
      "scope": "team:7",
      "actor": {"id": 12, "username": "alice", "email": "[email protected]"},
      "channel": "web",
      "automatic": false,
      "role": "maintainer",
      "environment": "Production",
      "action": "approval.bash_command",
      "outcome": "approved",
      "target": {"server_id": 45, "hostname": "gpu-01", "chat_id": 123, "resource": ""},
      "approval_id": "a1b2…",
      "correlation_id": "a1b2…",
      "parameters": {"command": "systemctl restart nginx"},
      "result": {},
      "prev_hash": "…",
      "entry_hash": "…"
    }
  ]
}

What is recorded

  • Approval decisions (approval.bash_command, approval.plan, approval.repo_trust), written before the decision takes effect. Each one has the person who decided, the channel, whether the decision was automatic (a permit list or Auto-approve), their role and the host's environment at that moment, and the outcome: approved, rejected, cancelled or timed_out.
    • A multi-host approval writes one event per host. A host you unticked on the card is recorded as rejected.
    • A timeout has no person and the channel system.
  • SARA's tool calls (tool.<name>), with the outcome succeeded, failed or awaiting_approval, and the error or exit code.

Integrity

  • The trail is append-only. Events can't be edited, and deleting them is restricted to an explicit purge.
  • Text is redacted for secrets and cut at 4,096 characters.
  • Events are kept until they are explicitly deleted.
  • Each scope is a hash chain. prev_hash is the entry_hash of the previous event in the same scope (64 zeros for the first one). A changed or removed event breaks the chain for every event after it.

Verify the chain

entry_hash is the SHA-256 of a canonical JSON document built from the exported event: the fields below, with keys sorted and no whitespace. You can recompute it from the export without trusting SkyPortal:

import hashlib
import json


def entry_hash(event: dict) -> str:
    """Recompute entry_hash for one event from GET /api/v1/audit/events/."""
    payload = {
        "scope": event["scope"],
        "actor_id": event["actor"]["id"],
        "actor_username": event["actor"]["username"],
        "actor_email": event["actor"]["email"],
        "channel": event["channel"],
        "automatic": event["automatic"],
        "role": event["role"],
        "environment": event["environment"],
        "action": event["action"],
        "outcome": event["outcome"],
        "server_id": event["target"]["server_id"],
        "server_hostname": event["target"]["hostname"],
        "chat_id": event["target"]["chat_id"],
        "resource": event["target"]["resource"],
        "correlation_id": event["correlation_id"],
        "parameters": event["parameters"],
        "result": event["result"],
        "prev_hash": event["prev_hash"],
        "created_at": event["created_at"],
        "approval_id": event["approval_id"],
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode()).hexdigest()


def verify(events: list[dict], previous: str = "0" * 64) -> bool:
    """Check events of one scope, in ID order, starting from the first event."""
    for event in events:
        if event["prev_hash"] != previous or entry_hash(event) != event["entry_hash"]:
            return False
        previous = event["entry_hash"]
    return True

Verify an export without filters: since, actor, outcome and the other filters skip events, so the chain looks broken. When you export in pages, pass the last entry_hash you already verified as previous for the next page. Keep your exports: a copy you hold is an independent check against later changes.

The hash covers each event's content and its place in the chain, but not its id. Events without a person, such as timeouts, still have an actor object, with id set to null and empty username and email.

The audit trail has no page in the web app and no SDK method yet. Use the API.