Skip to content
AGH RuntimeBridges

Build an In-Tree Bridge Provider

Prove the reference adapter, then design, implement, verify, and document a trusted bridge.adapter extension inside the AGH repository.

Audience
Operators running durable agent work
Focus
Bridges guidance shaped for scanability, day-two clarity, and operator context.

This guide is for contributors adding a provider under extensions/bridges/<provider> to the AGH repository. It starts with a working reference adapter, then follows one provider from contract decisions to manifest, ingress, delivery, conformance, and operator proof.

This walkthrough is specifically for trusted, in-tree Go providers. A local external extension can implement the subprocess protocol manually, but external bridge authoring is not yet a first-class public SDK workflow. The public SDKs do not cover the complete service and control handshakes, marketplace bridge grants are unavailable, and there is no public bridge conformance harness. Do not copy these internal/ imports into another module or present this tutorial as an external SDK path.

Prerequisites

  • Run every command from the AGH repository root.
  • Have Go and the current agh binary available.
  • Start the daemon before the install/create/verify checkpoints.
  • Create or choose a workspace with a default agent for inbound testing.
  • Use a fake provider HTTP server or a dedicated provider test account; committed tests must not need live credentials.
  • Decide the public callback strategy and one unused local listener address.

Prove the working reference first

Before creating a directory, run the real Telegram reference adapter and its subprocess boundary:

CGO_ENABLED=1 go test -race ./sdk/examples/telegram-reference -count=1
CGO_ENABLED=1 go test -race -tags=integration ./internal/extension \
  -run '^TestTelegramReferenceAdapter(IngressAndDeliveryConformance|RestartResumesActiveDelivery)$' \
  -count=1

The first command proves authenticated fake ingress, delivery, progress, lifecycle, and provider state. The second launches the compiled subprocess and proves initialize, ingest, delivery acknowledgement, shutdown, and restart resume through the same JSON-RPC boundary used by in-tree providers. Keep these tests green while replacing provider-specific behavior.

The implementation sections use acme-chat as a naming placeholder, not as an unpublished finished adapter. Replace it consistently; mixed directory, extension, platform, and runtime names fail discovery or ownership checks. Use sdk/examples/telegram-reference whenever a complete executable checkpoint is needed and the in-tree providers when the target platform needs richer lifecycle or delivery behavior.

Choose an implementation to study

The shared SDK removes lifecycle boilerplate, but provider behavior is easiest to learn from a working owner:

NeedStart with
CI-safe fake ingress, delivery ACKs, and crash markerssdk/examples/telegram-reference
Signed webhook, editable delivery, and progressextensions/bridges/slack
Remote webhook registration and control runtimeextensions/bridges/telegram
Ed25519 interactions and webhook eventsextensions/bridges/discord
Conditional direct/Pub/Sub ingressextensions/bridges/gchat
Bot Framework JWT and tenant-aware deliveryextensions/bridges/teams
Append-only/no-op progress issue providerextensions/bridges/github or linear

The Telegram reference proves the protocol and integration harness. The in-tree providers show the modern ProviderLifecycle + ManagedConfigReconciler + ProviderHTTPServer composition used in production.

Architecture

Rendering diagram…

The provider owns platform protocol truth; AGH owns workspace routing, sessions, persistence, and ordered delivery.

The boundary is deliberate. The daemon never needs to learn a provider's signature scheme or REST API, and the provider never owns AGH sessions or workspace routing.

Decide the contract before writing code

Write down these decisions first:

DecisionQuestions to answer
Provider identityWhat lowercase key will be used for directory, extension name, platform, runtime, and schema?
Inbound transportWebhook, polling, subscription, or hybrid? What owns each listener/task and its shutdown?
Callback contractWhat is the external acknowledgement deadline? When does the provider retry? When has AGH durably accepted the event?
AuthenticationWhich exact bytes are signed? Which header/token/JWT claims are required? What is the replay window?
Secret slotsWhich credentials are always required? Which are conditional on an auth or ingress mode?
Route identityWhich provider IDs become peer_id, group_id, and thread_id? How are replies and edits represented?
Delivery capabilityCan the platform create, edit, delete, reply, type, react, and resume? What is the message-length unit?
Mutation completionWhich observation proves a remote mutation committed, and which required result must be materialized locally?
VerificationWhich identity/configuration checks can run while disabled? Can AGH register the webhook remotely?
Data scopeIs each new datum global, workspace, session, or agent scoped? How is workspace_id preserved?

Required slots must be required in every mode. A mode-specific credential stays optional in the manifest and becomes a conditional requirement in runtime validation and setup documentation.

Current contract boundaries

  • Inbound messages can carry attachment metadata and typed message, command, action, reaction, and edit families.
  • Bridge delivery v1 is text-oriented. Generic outbound media upload and provider-native interactive controls are not part of the shared delivery contract.
  • Unsupported text operations return permanent errors. They never report a successful no-op.
  • Progress can be rendered by capable chat providers or acknowledged without side effects.
  • Target capabilities are provider metadata; do not invent strings as behavior switches without a shared consumer and contract change.

The operator-facing matrix in Supported bridge behavior is the public contract. Update it when a provider changes an accepted event family, route shape, outbound operation, attachment boundary, or progress affordance.

Know what the SDK owns

Shared ownerWhat it handlesWhat your provider still decides
RuntimeJSON-RPC method registration, initialize/session cache, default target snapshots, progress-only no-op acknowledgementsDelivery, provider checks, optional webhook registration, provider health
ProviderLifecycleHost synchronization, initial state reporting, goroutine ownership, health error, shutdown joinReconciliation result, provider resources, state/degradation reason
ManagedConfigReconcilerFull-snapshot resolve/prepare/publish/finalize order and atomic route replacementConfig decoding, conflicts, listeners, identity probes, cleanup callback
RouteTableInstance and alternate-key lookup with atomic replacementWhich paths or ownership keys index a config
ProviderHTTPServerOne listener/server lifecycle and cooperative shutdownHandler security, body limits, method/content type, response semantics
ProviderHostTyped list/get/report-state/ingest calls with lifecycle-aware retryWhen a platform event is safe to ingest
DeliveryStateStoreProvider-local typed delivery snapshotsSnapshot contents and platform create/edit/delete rules
ProgressAccumulator / ProgressDispatcherGrouping, throttled edits, affordance ordering, shutdownPlatform sink implementation and whether progress should render at all
AdapterMarkersConformance/integration process markersNo provider-local copy; only provider name and error handling

Composition does not replace platform logic. Your package still owns authentication, normalization, API requests, rate-limit parsing, message formatting, conditional credentials, and unsupported operation errors.

Create the provider directory

Split by responsibility before files grow:

extensions/bridges/acme-chat/
├── extension.toml
├── bin/                    # local build output; ignored by Git
├── main.go                 # thin bridgesdk command entry
├── provider.go             # composition root only
├── provider_config.go      # decode, resolve, validate, reconcile hooks
├── webhook.go              # HTTP contract and dispatch
├── webhook_auth.go         # exact signature/JWT verification
├── inbound_mapping.go      # provider payload → Bridge envelope
├── api_client.go           # outbound provider API
├── delivery.go             # target + create/edit/delete/resume
├── control.go              # provider-owned checks/registration
├── progress.go             # only when the platform renders progress
├── provider_test.go
├── provider_delivery_test.go
└── control_test.go

Not every provider needs every file, but production source must stay below 500 lines and one file must not become a registry, transport, formatter, and lifecycle owner at once.

Create the ignored output directory before the first build. go build -o does not create parent directories:

mkdir -p ./extensions/bridges/acme-chat/bin

Step 1: Copy the manifest template

Replace every acme-chat value and declare the exact slot contract:

[extension]
name = "acme-chat"
version = "0.1.0"
description = "Acme Chat bridge provider built on internal/bridgesdk"
min_agh_version = "0.5.0"

[capabilities]
provides = ["bridge.adapter"]

[bridge]
platform = "acme-chat"
display_name = "Acme Chat"

[[bridge.secret_slots]]
name = "bot_token"
description = "Acme Chat bot token"
required = true

[[bridge.secret_slots]]
name = "webhook_secret"
description = "Acme Chat webhook signing secret"
required = true

[bridge.config_schema]
schema = "agh.bridge.acme-chat"
version = "1"

[actions]
requires = [
  "bridges/instances/list",
  "bridges/instances/get",
  "bridges/instances/report_state",
  "bridges/messages/ingest",
]

[subprocess]
command = "./bin/acme-chat"
args = ["serve"]

[subprocess.env]
AGH_BRIDGE_ACME_CHAT_LISTEN_ADDR = "{{env:AGH_BRIDGE_ACME_CHAT_LISTEN_ADDR}}"

[security]
capabilities = ["bridge.read", "bridge.write"]

Do not put credential-bearing upstream destinations in provider_config. Use fixed official defaults and explicit operator-owned process variables for trusted sovereign/test overrides. The daemon rejects instance fields such as api_base_url, oauth_token_url, service_url, openid_metadata_url, and token_url.

Manifest checkpoint

Before the adapter exists, confirm:

  • directory, extension name, platform, and schema suffix are identical;
  • at least one slot is required;
  • optional slots are explained by a conditional runtime mode;
  • actions and security capabilities are the minimal bridge Host API set;
  • the subprocess path matches the binary you will build.

Step 2: Copy the runtime bootstrap

This main.go compiles and participates in lifecycle conformance. It deliberately fails delivery until you implement it; never acknowledge a platform message that was not sent.

package main

import (
    "context"
    "errors"
    "io"

    bridgepkg "github.com/compozy/agh/internal/bridges/contract"
    "github.com/compozy/agh/internal/bridgesdk"
    "github.com/compozy/agh/internal/subprocess"
)

func main() { bridgesdk.Main("acme-chat", serve) }

func serve(stdin io.Reader, stdout io.Writer, _ io.Writer) error {
    lifecycle, err := bridgesdk.NewProviderLifecycle(bridgesdk.ProviderLifecycleConfig{
        ProviderName: "acme-chat",
    })
    if err != nil {
        return err
    }
    runtime, err := bridgesdk.NewRuntime(bridgesdk.RuntimeConfig{
        ExtensionInfo: subprocess.InitializeExtensionInfo{
            Name: "acme-chat", Version: "0.1.0", SDKName: "bridgesdk",
        },
        Initialize: lifecycle.Initialize,
        Deliver: func(
            context.Context,
            *bridgesdk.Session,
            bridgepkg.DeliveryRequest,
        ) (bridgepkg.DeliveryAck, error) {
            return bridgepkg.DeliveryAck{}, errors.New("acme-chat: delivery not implemented")
        },
        Check: func(
            _ context.Context,
            _ *bridgesdk.Session,
            _ bridgepkg.BridgeCheckRequest,
        ) (bridgepkg.BridgeCheckResponse, error) {
            return bridgepkg.BridgeCheckResponse{
                Checks: []bridgepkg.BridgeCheckRecord{
                    bridgepkg.SkippedCheck(
                        "provider.identity",
                        "Implement the Acme Chat identity probe before enabling this provider.",
                    ),
                },
            }, nil
        },
        HealthCheck: func(context.Context, *bridgesdk.Session) error {
            return lifecycle.Health()
        },
        Shutdown: lifecycle.Shutdown,
    })
    if err != nil {
        return err
    }
    return lifecycle.Serve(context.Background(), runtime, stdin, stdout)
}

Build from the repository root:

mkdir -p ./extensions/bridges/acme-chat/bin
go build \
  -o ./extensions/bridges/acme-chat/bin/acme-chat \
  ./extensions/bridges/acme-chat

At this checkpoint, build and initialize can work. Delivery still returns a truthful error, and identity verification returns a valid skipped record with remediation. An empty checks array is not a valid control response.

Step 3: Resolve and reconcile managed instances

Define a provider config DTO that contains only instance-owned settings. Resolve secrets by exact manifest slot name through Session.Cache().

Wire the provider in this order:

newProvider
  ├─ create RouteTable[resolvedInstanceConfig]
  ├─ create DeliveryStateStore[deliveryState]
  ├─ create ProviderLifecycle
  │    ├─ Reconcile → provider.reconcileInstanceConfigs
  │    ├─ OnStop → stop timers, batchers, pollers, and per-instance state
  │    └─ ShutdownResources → ProviderHTTPServer.Shutdown
  ├─ create ProviderHTTPServer
  │    ├─ Handler → authenticated provider webhook handler
  │    └─ Go → lifecycle.Go
  ├─ create Runtime
  │    ├─ Initialize → lifecycle.Initialize
  │    ├─ Deliver / Progress / Check → provider handlers
  │    ├─ HealthCheck → provider health
  │    └─ Shutdown → lifecycle.Shutdown
  └─ serve with lifecycle.Serve

Use extensions/bridges/slack/provider.go as the concrete composition owner and sdk/examples/telegram-reference as the protocol-level fake. Do not paste an isolated reconciler literal with undefined provider methods into a new package.

Inside reconcileInstanceConfigs, use one ManagedConfigReconciler for the full snapshot. Its callbacks have distinct jobs:

CallbackProvider responsibility
ResolveDecode instance config, read exact secret slots, normalize IDs/paths, build initial local state.
PrepareDetect ownership/path/listener conflicts and start deterministic resources.
FinalizeRun bounded live probes and assign truthful initial status/degradation.
MergePreserve or replace per-instance resources without leaking old timers/batchers.
OnRemovedDetach and close resources owned by an instance no longer in the managed snapshot.
OnPublishClose resources only after the new route snapshot is visible.

Use Prepare for deterministic local work: ownership conflicts, listener selection, path conflicts, and resource startup. The first publish admits routes. Use Finalize for bounded live identity probes and publish the resulting status/degradation snapshot again.

Do not append a credential destination to the DTO for test convenience. Put the trusted seam in the provider process environment and validate the default/override host before sending a credential.

Step 4: Authenticate and normalize inbound events

A webhook handler must apply this order:

  1. Match the HTTP method and content type.
  2. Bound the body before parsing.
  3. Apply rate and in-flight limits.
  4. Preserve the raw body for signature verification.
  5. Select exactly one bridge instance without trusting an unauthenticated tenant field alone.
  6. Verify the signature/token/JWT for that selected instance.
  7. Parse only supported event types and actions.
  8. Map stable provider IDs into peer_id, group_id, and thread_id.
  9. Apply the DM/ACL policy.
  10. Validate the InboundMessageEnvelope.
  11. Check adapter-local deduplication with Seen without recording the key.
  12. Ingest through provider.lifecycle.Host() so lifecycle-aware retry and shutdown admission remain active.
  13. Record the adapter-local key with Mark only after the Host accepts the event.

A message mapper must preserve the managed instance's scope and workspace rather than deriving ownership from the provider payload:

envelope := bridgepkg.InboundMessageEnvelope{
    BridgeInstanceID:  managed.Instance.ID,
    Scope:             managed.Instance.Scope,
    WorkspaceID:       managed.Instance.WorkspaceID,
    PeerID:            event.UserID,
    GroupID:           event.ChannelID,
    ThreadID:          event.ThreadID,
    PlatformMessageID: event.MessageID,
    ReceivedAt:        event.Timestamp.UTC(),
    Sender: bridgepkg.MessageSender{
        ID:          event.UserID,
        Username:    event.Username,
        DisplayName: event.DisplayName,
    },
    EventFamily:    bridgepkg.InboundEventFamilyMessage,
    Content:        bridgepkg.MessageContent{Text: event.Text},
    IdempotencyKey: "acme-chat:" + event.EventID,
}
if err := envelope.Validate(); err != nil {
    return fmt.Errorf("acme-chat: validate inbound message: %w", err)
}
if provider.dedup.Seen(envelope.IdempotencyKey) {
    return nil
}
if _, err := provider.lifecycle.Host().IngestBridgeMessage(ctx, session, envelope); err != nil {
    return fmt.Errorf("acme-chat: ingest message: %w", err)
}
provider.dedup.Mark(envelope.IdempotencyKey)

event is the provider payload after authentication and normalization. Use the matching typed payload for command, action, reaction, and edit families; never encode those operations as decorated message text.

The shared adapter-local deduplication cache defaults to a five-minute TTL and lives only in the provider process. Seen is the read-only check; Mark both checks and records. For a synchronous callback, do not use Mark as the pre-ingest check: a Host failure has no single-key rollback, so an immediate provider retry would be suppressed even though no prompt was admitted. The daemon's separate 24-hour record is durable and is written only after successful prompt submission.

Record reply text from an embedded provider snapshot or a bounded local cache only. A cache miss must not fetch arbitrary provider history during prompt construction.

For a polling provider, the same authentication/mapping rules apply, but the poll loop must run under ProviderLifecycle.Go, observe the stop channel, and persist provider cursors only at a layer that can prove restart semantics.

Define the callback acknowledgement boundary

Document the platform's exact acknowledgement deadline and retry behavior in the provider setup guide. The safe synchronous boundary is: authenticate and authorize, validate, check Seen, call IngestBridgeMessage, call Mark, then return platform success. A routed session that is already prompting can keep the Host call waiting for roughly five seconds before it returns an error.

If the platform requires an earlier acknowledgement, treat that as a provider-specific exception and document the resulting retry and loss behavior. Discord interactions, for example, send a deferred acknowledgement before asynchronous Host ingestion. Positive inbound batching acknowledges after an in-memory enqueue and before the later Host call; a flush failure cannot be returned to the platform, and pending batches are lost if the adapter process restarts. Neither path has automatic readmission.

The current Host path has no shared durable “ack now, enqueue later” contract. Do not describe an early acknowledgement as accepted prompt admission, and do not copy an existing early-Mark or batching sequence as the default for a new provider. Test the accepted path, the busy/error path, and every provider-specific early-ack path with a fake Host and a deadline representative of the platform.

Step 5: Implement delivery and acknowledgements

The daemon sends one closed event family:

EventProvider action
startCreate or prepare the first mutable remote message when the provider uses streaming previews.
deltaUpdate the current full-text preview; the payload is not merely the newest token fragment.
finalMaterialize the complete terminal text and acknowledge the final remote anchor.
errorRender the terminal failure behavior supported by the platform.
resumeReconcile from the supplied DeliverySnapshot after provider recovery.
deleteDelete only when a real provider API and target reference exist.
progressRender presentation-only tool state or acknowledge with empty remote IDs and no provider side effect.

Handle each platform capability explicitly:

  • create a remote message for new content;
  • edit only when the platform and target support it;
  • delete only when a real provider API exists;
  • preserve an explicit edit/reply reference over stale local state;
  • split terminal content with the platform's real unit and limit;
  • send continuations in order and acknowledge the last materialized remote ID;
  • keep progress message IDs separate from final-text delivery state;
  • preserve HTTP status for provider overload and server failures, and classify auth, rate-limit, timeout, transient, and permanent failures with bridgesdk.

Classify completion from the remote observation, not from the local error alone:

ObservationProvider resultSafe to retry?
Transport failed before any successful responseTyped auth, rate-limit, transient, timeout, or permanent errorAccording to the typed class
Remote status rejected the mutation transientlyTyped retryable HTTP/provider errorYes, according to provider policy
Remote status rejected the mutation permanentlyPermanentErrorNo
Successful status and every required result was materializedNormal delivery acknowledgement with the real remote ID when requiredNo retry needed
Successful status, but response read/decode/cleanup or required ID became unavailableMarkCommittedMutation or CommittedMutationErrorNo; replay could duplicate the remote side effect
Read-only identity or configuration probe failedNormal typed error; never mark a mutation committedAccording to the read operation

Build acknowledgements through session.AckDelivery so delivery ID, sequence, remote ID, replacement ID, and event family are validated together. A provider that cannot perform an operation returns a permanent unsupported error; it never reports a successful no-op for text delivery.

The JSON-RPC response must materialize an acknowledgement object with an explicit non-empty delivery_id and an explicit integer seq, both exactly matching the request. seq: 0 is valid; omitting seq, returning it as null, or changing its type is not. A missing, malformed, or mismatched acknowledgement leaves the provider commit outcome unknown, so the manager returns committed_result_unavailable and the broker does not replay the mutation. session.AckDelivery constructs this wire identity correctly; do not hand-build a partial acknowledgement.

A successful create/final branch ends with the provider's real response ID:

remoteID, err := client.PostMessage(ctx, target, request.Event.Content.Text)
if err != nil {
    return bridgepkg.DeliveryAck{}, fmt.Errorf("acme-chat: post message: %w", err)
}
ack, err := session.AckDelivery(request, remoteID, "")
if err != nil {
    return bridgepkg.DeliveryAck{}, fmt.Errorf("acme-chat: acknowledge delivery: %w", err)
}
return ack, nil

client.PostMessage is your provider API boundary. For an edit, preserve the explicit request reference over stale local state and put the replaced anchor in replaceRemoteMessageID when the platform returns a new message. For progress with no side effect, use session.AckDelivery(request, "", "").

Map provider failures to bridgesdk auth, rate-limit, overload, server-error, timeout, transient, or permanent classes. Report classified failures through the lifecycle-aware session path so runtime state and retry decisions stay consistent.

First-party in-tree providers retry outbound calls through bridgesdk.RetryDo. Keep the original HTTP status in HTTPError: status 529 is an overloaded failure with the slower overload profile, while 500, 502, and 503 are server_error; transport failures such as connection reset remain transient. The shared runner uses bounded decorrelated jitter so concurrent deliveries do not advance in a fixed lockstep sequence. A positive provider Retry-After is authoritative and is not jittered or shortened.

Do not add a provider-local attempt loop or backoff helper. This retry path is for first-party outbound provider calls only; it does not apply to delegated ACP agents or durable automation schedules. CommittedMutationError remains terminal regardless of class or retry configuration.

Send every credential-bearing API, OAuth, and service request through a client created with bridgesdk.CredentialedHTTPClient(baseClient). The helper clones the configured base client and refuses redirects, so a provider 3xx returns to your adapter for normal non-success classification without forwarding credentials or replaying a mutation body to Location. Do not replace this with an auto-following redirect policy, even for a same-provider URL.

For mutating HTTP calls, use HTTPResponseCommitOnSuccessStatus only after a 2xx status has been observed. Decode exactly one JSON value with DecodeSingleJSONValue when the response carries a result. A transport helper can defer FinalizeHTTPResponseBody; once its commit evidence is set, a decode failure or missing required ID must leave the handler as a committed-mutation error:

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
    return classifyHTTPError(resp)
}
commitEvidence = bridgesdk.HTTPResponseCommitOnSuccessStatus.Evidence()

var result createMessageResponse
if err := bridgesdk.DecodeSingleJSONValue(resp.Body, &result); err != nil {
    return fmt.Errorf("acme-chat: decode create response: %w", err)
}
if strings.TrimSpace(result.ID) == "" {
    return errors.New("acme-chat: create response did not include a message id")
}

In this pattern, the deferred finalizer wraps either returned error with MarkCommittedMutation. If the transport is structured differently, mark the post-2xx failure explicitly. Do not classify it as transient, fabricate an ID, or issue a second create.

The bridgesdk runtime converts CommittedMutationError into the semantic committed_result_unavailable acknowledgement. The broker writes a terminal error checkpoint and does not redeliver the text mutation. For progress, AGH drops only the indeterminate progress bubble and allows later final text to continue.

Expose discoverable targets

The SDK can publish a fallback snapshot from operator-declared delivery defaults. Implement provider enumeration only when the platform has a bounded, truthful target API.

Each target needs a provider-derived immutable route, operator-facing display name, target type, optional qualifier, and last-seen timestamp. Do not use a display name as the canonical route. Test the result through:

agh bridge targets "$BRIDGE_ID" --query launch -o json
agh bridge resolve "$BRIDGE_ID" "launch room" -o json

Ambiguous names must remain ambiguous. Never select the first match silently just to make send-test convenient.

Step 6: Add checks and optional webhook registration

Check runs in a short-lived control runtime. It has no Host API grants and must not start the service listener. Return stable records such as:

  • provider.configuration for decoded mode/tenant/listener requirements;
  • provider.identity for a bounded credential probe, or explicit skipped when the provider cannot truthfully probe in isolation;
  • webhook configuration and reachability through shared bridgesdk helpers.

The control runtime contains exactly the requested managed instance in Session.Cache(). Service Initialize, listeners, state reporting, and background goroutines do not run. A control handler that calls Session.HostAPI() is incorrect.

Build at least one valid record and append shared webhook checks:

managed, ok := session.Cache().Get(request.BridgeInstanceID)
if !ok {
    return bridgepkg.BridgeCheckResponse{}, fmt.Errorf(
        "acme-chat: bridge instance %q is not in the control session",
        request.BridgeInstanceID,
    )
}
checks := []bridgepkg.BridgeCheckRecord{
    bridgepkg.SkippedCheck(
        "provider.identity",
        "Implement the Acme Chat identity probe before enabling this provider.",
    ),
}
checks = append(checks, bridgesdk.WebhookCheckRecords(ctx, managed.Instance, nil)...)
return bridgepkg.BridgeCheckResponse{Checks: checks}, nil

Implement RegisterWebhook only when the provider has a real remote registration API. Return status and remediation when registration is manual; do not simulate success.

Step 7: Decide progress behavior

With no Progress handler, the SDK acknowledges progress and a progress-only empty final without calling the text handler. That is the correct default for issue trackers and platforms without useful status affordances.

When the platform can render progress:

  1. implement a ProgressSink for post/edit/typing/reaction operations it actually supports;
  2. create one accumulator/dispatcher per delivery;
  3. use provider-specific message limits and dialect formatting;
  4. close/detach dispatchers on terminal content, mode-off changes, removal, and shutdown;
  5. keep affordance failures observable without dropping the main progress line.

Step 8: Run the provider locally

Build and install the extension only after delivery and checks are honest:

mkdir -p ./extensions/bridges/acme-chat/bin
go build \
  -o ./extensions/bridges/acme-chat/bin/acme-chat \
  ./extensions/bridges/acme-chat

agh extension install \
  ./extensions/bridges/acme-chat \
  --allow-unverified \
  --yes \
  -o json
agh extension status acme-chat -o json

Create a disabled workspace bridge:

WORKSPACE_ID=ws_8f33a913d23c4fd1

agh bridge create \
  --scope workspace \
  --workspace-id "$WORKSPACE_ID" \
  --platform acme-chat \
  --extension acme-chat \
  --display-name "Acme Chat development" \
  --enabled=false \
  --include-peer \
  --provider-config '{
    "webhook": {
      "public_url": "https://bridge.example.com/acme/dev",
      "listen_addr": "127.0.0.1:18090",
      "path": "/acme/dev"
    }
  }' \
  -o json

This example assumes acme-chat emits a direct-conversation peer_id. Select only dimensions that coexist on every inbound event accepted by this instance. If the provider supports alternative shapes such as peer-only DMs and group-plus-thread channels, create separate bridge instances.

Copy the returned ID, then bind the two manifest slots:

BRIDGE_ID=brg_123

printf '%s' "$ACME_CHAT_BOT_TOKEN" | agh bridge secret-bindings put "$BRIDGE_ID" bot_token \
  --secret-ref "vault:bridges/$BRIDGE_ID/bot_token" \
  --kind token \
  --secret-value-stdin

printf '%s' "$ACME_CHAT_WEBHOOK_SECRET" | agh bridge secret-bindings put "$BRIDGE_ID" webhook_secret \
  --secret-ref "vault:bridges/$BRIDGE_ID/webhook_secret" \
  --kind secret \
  --secret-value-stdin

agh bridge secret-bindings list "$BRIDGE_ID" -o json

The list output must contain redacted binding metadata, not plaintext. The generic CLI accepts secret contents only from --secret-value-stdin or --secret-value-file <path> and rejects inline values. Document the safer input appropriate for the provider's credential format.

Verify before enablement:

agh bridge verify "$BRIDGE_ID" --json

At the initial scaffold checkpoint, provider.identity is explicitly skipped. Replace it with a bounded provider identity probe before calling the provider complete.

Enable the bridge, send one signed event through your fake platform, and inspect the public state:

agh bridge enable "$BRIDGE_ID"
agh bridge get "$BRIDGE_ID" -o json
agh bridge routes "$BRIDGE_ID" -o json
agh bridge targets "$BRIDGE_ID" -o json

agh bridge send-test "$BRIDGE_ID" \
  --peer-id "$ACME_CHAT_PEER_ID" \
  --message "AGH bridge provider smoke test" \
  --json

The final checkpoint is observable: the provider reaches ready, the fake inbound event creates a route in $WORKSPACE_ID, and send-test returns a delivery ID plus the provider's remote ID.

Step 9: Put tests in the owning suites

Before adding a test, name its invariant, owning layer, and canonical suite. Cover:

InvariantOwning suite
Config, conditional slots, signature/JWT, mapping, DM policy, dedupprovider_test.go
Create/edit/delete/resume, message limit, retry and commit classprovider_delivery_test.go
Identity/configuration checks and optional registrationcontrol_test.go
Full subprocess initialize/ingest/deliver/shutdowninternal/extension/<provider>_provider_integration_test.go
Manifest/schema/required-slot discovery and lifecycleauto-discovered provider conformance
Exact docs slots and setup coveragebridge docs conformance

Use fake platform servers, not live provider accounts, for committed tests. Assert request method, path, headers, body, response status, and cleanup.

Every mutating create path must include a successful-status response with malformed JSON or a missing required ID. The provider suite owns the proof that only one upstream mutation occurred and that the returned error is a CommittedMutationError. The shared bridgesdk and broker suites own the semantic ACK, terminal checkpoint, no-fabricated-ID, and no-redelivery invariants; do not duplicate those shared assertions in every provider.

For webhook providers, also prove the external acknowledgement is returned only after Host ingest accepts the event, and that a Host rejection does not produce a false success.

Run focused gates from the repository root:

go test -race ./extensions/bridges/acme-chat/...
go test -race -tags=integration ./internal/extension \
  -run '^TestAutoDiscoveredProviderRuntimeConformance$'
go test -race ./internal/extension \
  -run '^TestBridgeProviderDocsConformance$'

Step 10: Co-ship every public surface

  • Add the exact display name and slot row to the bridge overview.
  • Update supported behavior when the provider changes a public event, route, delivery, attachment, access, or progress boundary.
  • Add setup-acme-chat.mdx and navigation with provider-console steps, CLI setup, expected checks, enable/send-test, configuration reference, limits, security, and troubleshooting.
  • Add a provider README with build/install, runtime config, transport, delivery behavior, and explicit unsupported operations.
  • Update the bundled AGH skill with the provider's agent-manageable CLI/HTTP/UDS path.
  • Flag the new user-visible journey as untested in docs/qa/state.csv.
  • If shared contracts change, co-ship OpenAPI, generated SDK/Web types, CLI/API references, mocks, native descriptors, and the canonical AGH skill.

The provider catalog and secret slots are manifest-driven. The current Web setup experience still has provider-specific presentation metadata for in-tree providers; an unknown provider falls back to a generic profile. Audit web/src/systems/bridges/lib/bridge-setup.ts and either add truthful setup metadata or document the generic boundary. The manifest alone does not produce a full guided Web setup today.

Record the AGH Impact Audit in the implementation task:

  • native tools and descriptors;
  • extensibility, hooks, public SDKs, bundles, and marketplace policy;
  • workspace ID propagation across CLI/HTTP/UDS/core/store/Web/events;
  • official skills/agh/ behavior;
  • Web routes/components and documentation;
  • provider configuration lifecycle and trusted process environment.

A provider-only implementation must not invent a central registry, config.toml key, or UI-only management path.

Definition of done

RequirementEvidence
Manifest identity, schema, slots, and discoveryAuto-discovered provider conformance
Build, initialize, health, method negotiation, and shutdownAuto-discovered runtime conformance
Config, conditional slots, auth, mapping, DM policy, dedupCanonical provider suite under extensions/bridges/<provider>
Create/edit/delete/resume/chunk/ACK behaviorProvider delivery suite with a fake platform server
Checks and optional webhook registrationControl suite using the isolated control runtime
Cleanup on failure, cancellation, removal, and shutdownRace-enabled lifecycle/failure-path cases
Workspace and instance isolationExact cross-workspace/instance integration case
Setup names, exact slots, and public navigationBridge docs conformance plus focused site MDX/typecheck
Agent-manageable operationCLI/HTTP/UDS examples and official AGH skill audit
First real behaviorSigned fake inbound event + route + fake-provider send-test

Invalid auth/config must produce actionable state without leaking values. Unsupported provider operations must fail truthfully. Setup docs must let an operator locate each credential and complete provider-console configuration without reading Go code.

Author troubleshooting

SymptomLikely cause and next check
Provider is absent from the catalogValidate extension.toml, identity consistency, install/enable state, and bridge.adapter capability.
First go build -o fails with no directoryCreate extensions/bridges/<provider>/bin first; the directory is ignored and absent in a fresh checkout.
Initialize rejects methods or grantsCompare manifest actions, runtime registration, extension name/version, and handshake purpose with an in-tree provider.
agh bridge verify says response is invalidReturn at least one unique valid check. Empty checks, bad statuses, or skipped/fail records without remediation are invalid.
Verify opens a listener or Host API is nilControl handlers must use Session.Cache() only; service initialization and Host API grants are intentionally absent.
Instance remains auth_requiredRequired or conditional slot names do not match the manifest/runtime mode, or the provider probe rejected the credential.
Webhook returns unauthorizedPreserve raw body bytes; select one owned instance first; then verify its exact signature/token/JWT and replay boundary.
Ingest works globally but not in a workspaceCopy scope and workspace_id from the managed instance and validate the envelope before ingest.
send-test cannot find a destinationImplement/fix target snapshots or use the managed-instance fallback; inspect targets, resolve, and a route from real inbound.
Shutdown hangsA listener, goroutine, timer, batcher, poller, or dispatcher was created outside lifecycle ownership or not closed on failure.
Web setup shows generic/unknown guidanceThe provider is discovered, but in-tree Web presentation metadata was not audited/co-shipped.

Grep before review

PROVIDER=acme-chat
DISPLAY='Acme Chat'

rg -n "$PROVIDER|$DISPLAY" \
  "extensions/bridges/$PROVIDER" \
  packages/site/content/runtime/core/bridges \
  skills/agh docs/qa/state.csv

rg -n 'bridge\.adapter|bridge\.secret_slots|bridge\.config_schema|bridges/deliver' \
  "extensions/bridges/$PROVIDER"

rg -n 'api_base_url|oauth_token_url|service_url|openid_metadata_url|token_url' \
  "extensions/bridges/$PROVIDER"

rg -n 'TODO|FIXME|not implemented|chat\.postMessage|chat\.update|chat\.delete' \
  "extensions/bridges/$PROVIDER" \
  "packages/site/content/runtime/core/bridges/setup-$PROVIDER.mdx"

Inspect every match. Endpoint names must be trusted process seams or rejection tests; copied provider terminology and unfinished delivery code block review.

On this page