# App Runtime

> Canonical path: `specs/app/app-runtime.md`

Apps are app-kind Service Actors. There is no separate runtime actor subtype:
a runtime-capable App declares `capabilities.runtime` in its pinned manifest.
External Apps execute through the App Runtime API at `/app-runtime`; built-in
Apps such as Approval execute through `internal_handler`. Both follow the
same runtime capability and delivery contract. The transports below
(`internal_handler`, `callback`, `runner_stream`) describe how an App runtime
is reached, not separate entity types or protocols. Terminology is authoritative in
[`specs/service/service-actor-platform.md`](../service/service-actor-platform.md).

AgentTeam supports two App runtime execution modes on the same Service Actor substrate:

- built-in `internal_handler` Apps, such as Approval and Workflow;
- active external Apps using either an HTTPS callback or a runner stream.

This is a narrow external App onboarding and runtime surface, not a generic
developer registry editor. A workspace member supplies the App identity,
manifest, configuration, and one supported transport; AgentTeam validates and pins
the immutable App manifest used for approval and runtime authorization.

## Manifest authority

An external runtime-capable App manifest declares its runtime transport, events,
and scopes. A minimal runner-stream App looks like:

```json
{
  "schema_version": "1",
  "slug": "echo-app",
  "name": "Echo App",
  "version": "1.0.0",
  "scopes": ["app.mentions.read", "messages.write"],
  "capabilities": {
    "runtime": {
      "runtime": {
        "transports": ["runner_stream"],
        "default_transport": "runner_stream"
      },
      "events": ["agentteam.message.mention"],
      "scopes": ["app.mentions.read", "messages.write"],
      "config_schema": {"type": "object"}
    }
  }
}
```

For callback transport, `runtime.transports` contains `callback` and the
manifest has an HTTPS `callback.url`. The pinned manifest on the active
installation is the authority for transport, event, scope, target, and content
checks. Updating a catalog or definition row never expands an existing
installation automatically.

## Onboarding and authorization

External App admission has two role-specific commands. Both validate the same
narrow manifest and atomically record the App's workspace and original creator
attribution.

- A normal member uses
  `POST /api/v1/workspaces/:workspaceId/custom-app-requests`. It creates
  an unlisted definition plus a pending request in the shared
  `service_installation_requests` ledger, then waits for a workspace
  administrator to approve or reject it.
- A workspace owner or administrator uses
  `POST /api/v1/workspaces/:workspaceId/custom-apps`. It materializes the
  installation directly and never creates an approval request or Approval
  card.
- The creator is written as the first active App manager in the same creation
  transaction. Active App managers may use the installed App management
  surfaces for catalog visibility, configuration, credentials, avatar, manager
  list, channel membership, display name, and deletion. Workspace owners and
  administrators retain those same powers.
  Creator attribution remains immutable provenance rather than a separate
  hidden permission.
- Workspace-created Apps have an independent workspace-catalog visibility of
  `private` or `public`. It is stored on the workspace attribution rather than
  the global App or service definition. Existing Apps and omitted creation
  inputs default to `private`. An active App manager or workspace owner or
  administrator can see either value. An ordinary active workspace member can
  discover only an active `public` App and receives only its safe identity,
  description, and the installation/member identifiers needed to invite it to
  a channel. Public visibility never grants App-management authority or exposes
  configuration, connections, runtime setup, credentials, or managers.
- Review projection contains only the transport, callback URL, and requested
  workspace-catalog visibility. Secrets are never copied into approval cards
  or ordinary App reads. A pending member request remains private; approval
  applies the reviewed visibility atomically with installation activation.
- The direct administrator response contains the already-active installation
  and any applicable one-time callback signing secret or runner token. The
  member request response is secret-free: neither plaintext nor a proposed
  credential hash is written to the pending approval request.
- Approval materializes the member's active installation but does not issue a
  runtime credential. An active App manager or a workspace owner or administrator
  can claim the installation-scoped initial credential exactly once. A
  successful claim immediately completes setup and returns the plaintext only
  in that response.
- Initial claim authority exists only while the definition is still
  `third_party` and has its workspace attribution. First-party product Apps
  never enter this initial-credential lifecycle.
- An externally created App belongs to exactly one workspace. Its attribution cannot be reused by
  another workspace, and its definition cannot be discovered or recreated by
  guessing its slug or ID.

## Installed App identity management

A current active App manager or workspace administrator may change the display
name of one active external App through:

```text
PATCH /api/v1/workspaces/:workspaceId/app-installations/:installationId/display-name
```

Creator attribution alone grants no hidden access: the creator receives access
because successful creation makes them the first active App manager. The
mutation revalidates active manager or workspace administrator authority inside
the write transaction, conflicts while an installation request for the App is
pending, and updates the App definition plus its service-member name
projections atomically. Because App definitions are global, a definition
associated with more than one workspace is rejected rather
than renamed across workspace boundaries. The endpoint changes only the display
name; slug, avatar, description, manifest, version, capabilities, and
transports remain immutable, as does the pinned manifest and capability scope
ceiling. The active grant subset is separately narrowable through the App
permission surface below; it is never expandable beyond that ceiling.

A current active App manager or workspace owner or administrator may change the
workspace-catalog visibility of one active external App through:

```text
PATCH /api/v1/workspaces/:workspaceId/app-installations/:installationId/catalog-visibility
```

The request accepts only `private` or `public`. The mutation revalidates the
current manager or workspace-administrator authority and the active attributed
installation inside the write transaction. Changing an App from `public` to
`private` atomically removes its visible membership from every channel and
from channel-derived child scopes, removes every subscription whose permission
basis is one of those channels (including `app_mention` subscriptions), and
disables the corresponding target bindings.
Existing messages remain historical facts, but the private App cannot continue
receiving new channel deliveries. The response returns only the resulting
catalog visibility; it does not expose the identities of channels the acting
manager might not otherwise be allowed to inspect. The mutation does not change
the global App definition visibility, pinned manifest, grants, runtime
credentials, or installation status.

A current active App manager or workspace owner or administrator may narrow one
active external App's effective permission scopes through the App detail
permission surface:

```text
GET   /api/v1/workspaces/:workspaceId/app-installations/:installationId/permissions
PATCH /api/v1/workspaces/:workspaceId/app-installations/:installationId/permissions
```

The pinned manifest and runtime-capability scope ceiling is immutable: it is the union
of the top-level generic scopes and `capabilities.runtime.scopes`, exactly the set
granted at install. PATCH may narrow the active grant subset to any subset of
that ceiling but can never expand it; a requested scope outside the ceiling is
rejected, and the narrowed set must still satisfy the manifest co-constraints
(for example `app.mentions.read` requires `messages.write` and the
`agentteam.message.mention` event). GET and PATCH both revalidate active manager or
workspace-administrator authority and return existence-hiding 404 for anyone
else, so an ordinary member holding an installation ID cannot read the full
policy of an App turned private. PATCH is an optimistic-concurrency update keyed
on a sha256 digest of the active scope set and updates canonical
`service_grants` atomically in one transaction;
the runtime rechecks active grants on every operation, so a narrowed App fails
closed on removed scopes. The default active set is the full pinned ceiling, so
an existing App is unchanged until a manager narrows it.

For a member request, approval creates the active installation, pinned manifest,
grants, service member, target-binding authority, and a `ready_to_claim` state.
The administrator command creates those objects and the initial credential
directly. Pending or rejected requests have no runtime credential and cannot
receive mentions or invoke the runtime.

Rejecting or withdrawing the initial request removes the pending App from
Apps. Approval messages remain as terminal audit records and expose no setup
action.

The browser-facing initial credential endpoints are:

```text
GET  /api/v1/workspaces/:workspaceId/app-installations/:installationId/initial-credentials
POST /api/v1/workspaces/:workspaceId/app-installations/:installationId/initial-credentials/claim
```

The GET response is secret-free and reports `ready_to_claim`, `claimed`, or
`not_applicable`. The claim response contains plaintext only once and uses
`Cache-Control: no-store`. A successful claim atomically changes the status
from `ready_to_claim` to `claimed`; a second claim is rejected. Request reads,
ordinary App/installation reads, Approval projection, URLs, and diagnostics
never carry the plaintext. The browser's “I've saved it” action only clears the
one-time plaintext from that page and does not call another server endpoint.

Representative claim response (the credential field depends on the transport):

```json
{
  "runnerToken": "<shown once>"
}
```

## Mention delivery

After creation, an active App manager or a workspace owner or administrator may
add either a private or public App to a channel when that Human also has current
channel invitation authority. An ordinary active workspace member may add only
a public App, and only to a non-`#all` channel the member has joined and is
currently authorized to invite members to. Both the App detail picker and the
channel's ordinary Add people surface use the same server-owned admission rule.
Candidate lists are hints: the membership write rechecks the current workspace
attribution, catalog visibility or manager authority, installation, grants,
runtime capability, service member, and channel invitation authority under the mutation
locks. Missing attribution or authority fails closed without revealing a
private App. An unattributed external App also fails closed; only an exact
first-party product-service identity may retain its product-defined legacy
behavior.
First-party product services keep their product-defined channel behavior. AgentTeam
reserves one durable
`app_mention` service-event delivery only when all of the following are current:

- the installation is active;
- the pinned manifest declares `agentteam.message.mention`;
- the installation grants `app.mentions.read` and `messages.write`;
- an active service target binding exists for the source channel, thread, DM,
  or task scope;
- the App service member has the required visible membership.

The delivery is idempotent per source message and installation. Replies are
service-authored messages and preserve the original channel, reply-thread, or
task-discussion provenance.

A non-Task runtime reply is an autonomous ordinary message. The app Store owns
its complete `autonomous_store_recoverable` lifecycle defined by
[`scope-model.md`](../message/scope-model.md): it records the reply effect and
recoverable message outbox payload in the same transaction, and only after
commit submits the fresh full delivery to the workspace's shared fanout.
The first attempt applies current Human unread and the stable
`message.created` browser projection. An idempotent reply-effect replay does not
repeat first fanout; if the process exits after commit but before first fanout
finishes, outbox replay may restore `message.created` but cannot reconstruct the
unfrozen ordinary unread audience exactly.

A Task-target runtime reply appends a canonical `task_discussion` scope message
through the shared Task discussion seam with an ordinary `scope_fanout` outbox row
and frozen targeted audience, then prepares a store-owned recoverable post-commit
token so `ReplyAppRuntimeDelivery` submits the delivery to the workspace's shared
fanout only after commit. The lease-coordinated Task inline fanout owns first
delivery exactly-once and enters ordinary Task unread/notification and Agent
wake; idempotent reply-effect replay does not repeat first fanout, and a
post-commit crash window is repaired by the durable outbox replay of the frozen
targeted audience. The retired Task-only silent/no-outbox contract must not be
generalized to Channel, DM, or reply-thread App replies.

## External runtime API

The canonical external runtime root is:

```text
/api/v1/workspaces/:workspaceId/app-runtime
```

Runner credentials authenticate:

```text
GET  /stream
POST /heartbeat
POST /events:claim
POST /sessions/:sessionId/offline
```

Each claimed callback or runner delivery has a short-lived delivery token. That
token is restricted to its delivery and attempt and authorizes:

```text
GET  /deliveries/:deliveryId/context
POST /deliveries/:deliveryId/reply
POST /deliveries/:deliveryId/ack
POST /deliveries/:deliveryId/fail
```

Callback delivery uses the installed HTTPS endpoint. AgentTeam sends bounded callback
identity headers, an HMAC signature derived from the one-time callback signing
secret, and a delivery-scoped bearer token in the envelope. Callback hosts and
actual dial IPs are validated, redirects are rejected, and ambient proxies are
not used. The App uses the delivery token when reading context or writing the
reply/ack/fail result.

Runtime event payloads identify the installed service actor under the top-level
`app` object. Mention payloads carry App identity and installation fields there;
App Notification action payloads use the same root. No alternate actor root is
part of the runtime wire contract.

Runtime notification endpoints also exist under the same runtime root for Apps
whose pinned manifest and current grants include the notification capability.
They are described in [`app-notification.md`](./app-notification.md).

## Runtime security boundaries

- Runner credentials and delivery tokens are stored as hashes. Runtime
  credential plaintext is returned only by the applicable direct create,
  or initial claim response, both of which are non-cacheable.
- Every heartbeat, claim, context read, reply, ack, fail, and notification
  mutation rechecks the current App installation, pinned runtime capability, current
  grants, workspace, target binding, and addressed delivery.
- A runner credential cannot claim event kinds outside the closed App runtime
  allowlist.
- Delivery tokens cannot be moved between deliveries, attempts, App records,
  or workspaces.
- Context reads use the canonical scope visibility service and pin the source
  message even when it falls outside the bounded history page.
- Reply effects are idempotent and recorded in the durable delivery ledger.

## Deliberately unsupported

AgentTeam does not expose a generic developer App definition/version editor (the
narrow active-manager App display-name mutation above is not a developer
surface), an
arbitrary webhook protocol, arbitrary code execution, arbitrary HTTP actions,
or user-selected secrets. Expanding an App's scopes beyond the pinned
manifest/capability ceiling is unsupported: the App detail permission
surface only narrows the active grant subset (see above). GitHub and Gitea
webhooks remain
provider-specific ingress, and Agent-App MCP authorization remains independent
of App runtime credentials.
