Design: mount tiny-harness agents onto an existing FastAPI or Starlette app
Phase 2 of 3 (requirements → design → tasks). Derives from the approved
requirements.md. Reviewed withtesting-plan.mdat the onedesign-approvalgate.
Overview
running_harness is split into two parts:
Harness. A new public class. It holds one agent'sSettingsand options, and has an async lifecycle (async with harness:). The lifecycle connects Temporal, builds the runtime and starts the worker. It opens no port.harness.asgi_app(). A Starlette app for the developer to mount. It exists from construction and answers503until the harness has started. For FastAPI,tiny_harness.fastapi.harness_router(harness)turns the same routes into anAPIRouterthat carries the lifespan and shows in/docs.
running_harness, serve and the CLI become a thin layer: a Harness plus a uvicorn server.
Several harnesses in one process are isolated by these mechanisms:
- The task queue marks ownership. Each agent has its own queue, and the queue defaults to the agent id. Before an agent answers for a task or a push, it checks that the task's workflow runs on its own queue.
- A process-wide claims registry. It refuses a second harness with the same agent id, the same namespace and queue, or the same store file.
- A reference-counted Temporal pool. Harnesses with the same connection settings share one client. In embedded mode they share one dev server.
- A process-wide secrets registry. Every redactor masks the secrets of every agent in the process.
Agents in one service call each other over HTTP through their mounted routes, the same way as between services.
| Requirement | Component(s) |
|---|---|
| R1 Start without a server | Harness (service/harness.py), TemporalPool |
| R2 Mount on Starlette | HarnessApp, build_routes, PublicUrl (service/a2a/app.py) |
| R3 FastAPI | tiny_harness/fastapi.py (harness_router), extra tiny_harness[fastapi] |
| R4 Customize | HarnessContextBuilder (resolver), RouteOptions, AgentConfig |
| R5 Several agents | Claims, TemporalPool, owner checks in TemporalTaskStore and PushReceiver, per-agent heartbeat, SecretRegistry |
| R6 Agents call each other | PublicUrl in the card and the push URL; the unchanged RemoteAgent path; examples/multi_agent/ |
| R7 No change for existing users | running_harness and serve rebuilt on Harness; defaults equal to today's |
| R8 Docs | docs/sdk/mount.md, docs/configuration/agent.md, deploy guide, capability docs |
Architecture
Startup and shutdown
Each start step is pushed onto an AsyncExitStack as it succeeds. A failure part-way undoes exactly the steps that ran, in reverse order, and the claims are released last.
Components & interfaces
Harness — tiny_harness/service/harness.py (R1, R4.6, R5)
class Harness:
def __init__(
self,
settings: Settings,
*,
client: Client | None = None, # R1.3: yours, never closed by us
with_worker: bool = True,
prefix: str | None = None, # R2.4
participant: ParticipantResolver | None = None, # R4.1
routes: RouteOptions = RouteOptions(), # R4.4, R4.5
agent: AgentDescription | None = None, # R4.6, in code; else [agent]
plugins: Sequence[Plugin | Path] = (),
commands: Iterable[tuple[CommandSpec, CommandHandler]] = (),
builtin_commands: bool = True,
llm: LLM | None = None, # build_runtime's existing seam: a custom or scripted model
log_stream: TextIO | None = None,
) -> None: ...
async def __aenter__(self) -> Self: ... # start; raises HarnessConflictError, ConfigError
async def __aexit__(self, *exc: object) -> None: ...
def lifespan(self, app: object) -> AbstractAsyncContextManager[None]: ... # Starlette/FastAPI shape
@property
def started(self) -> bool: ...
@property
def agent_id(self) -> str: ...
@property
def public_url(self) -> str: ... # base_url with the prefix, no trailing slash
@property
def client(self) -> Client: ... # raises HarnessNotStartedError before start
def asgi_app(self) -> HarnessApp: ... # the same object on every call
def harnesses_lifespan(*harnesses: Harness) -> Callable[[object], AbstractAsyncContextManager[None]]:
"""One lifespan that starts every harness in order and stops them in reverse."""Harnessis exported fromtiny_harness.service, through the same lazy_EXPORTSmap asrunning_harness.- The constructor does no I/O. It validates the prefix against
base_url(R2.4) and computespublic_url, so a mismatch fails at construction, before any lifespan runs. __aenter__raisesHarnessAlreadyStartedErrorwhen called a second time, andHarnessStoppedErrorwhen called after exit. AHarnessruns once.asgi_app()andharness_router()read the options passed to the constructor. Every option is set in one place, so the app and the runtime cannot disagree. One example:routes.push=Falsemust also turn off push registration on remote tasks (R4.5).ParticipantResolver = Callable[[Request], str | None]. It is synchronous because the SDK'sServerCallContextBuilder.buildis synchronous. Host auth middleware has already put the user onrequest.stateorrequest.scope["user"]by then.
RouteOptions (R4.4, R4.5)
@dataclass(frozen=True)
class RouteOptions:
limits: bool = True # LimitsMiddleware (max_request_bytes, rate_limit_per_minute)
cors: bool = True # CORSMiddleware when [server] cors_origins is set
monitor: bool = True # GET /_monitor
push: bool = True # POST /a2a/push/{task_id}; False ⇒ remote agents use follow
ui: bool = True # /ui when [server] ui_dir is setWhen push=False, Activities is built with push_base_url=None. The existing branch in activities.py (if agent.updates == "follow" or not agent.push_supported or not self.push_base_url) then uses follow, so nothing new is needed there.
HarnessApp and build_routes — tiny_harness/service/a2a/app.py (R2, R4)
create_app is replaced by two functions:
build_routes(handler: RequestHandler, card: CardSource, *, options, monitor, push)returns the route list. Every route is built at definition time against aDeferredRequestHandler. That is an SDKRequestHandlerwhose methods delegate to the realHarnessRequestHandleroncestartsets it. The card route uses the SDK'screate_agent_card_routes(placeholder, card_modifier=…), and the modifier returns the card built at start. Route order is kept from today: the card,/_monitor, push,/ui, then JSON-RPC at/and HTTP+JSON.HarnessAppis an ASGI callable. It holds a Starlette app over those routes, wrapped in limits and CORS asRouteOptionssay, and adds three behaviours:- The 503 gate (R2.2). WHILE the harness is not started, every
httprequest is answered503 {"error": "agent <id> is not started"}. The gate reads one boolean, and the request reaches no route. - Lifespan when top-level. A
lifespanscope starts and stops the harness unless something else already started it. Souvicorn.run(Harness(s).asgi_app())works. Under aMount, Starlette never sends this scope; the host's lifespan starts the harness instead. - The mount check. On the first request,
scope["root_path"]must end with the configured prefix. IF it does not THEN the app answers500, logs onceagent <id> is mounted at <root_path> but configured for <prefix>, and keeps refusing. A wrong mount would put wrong URLs in the card, and a remote agent would then push to the wrong place.
- The 503 gate (R2.2). WHILE the harness is not started, every
HarnessContextBuilder gains the resolver:
class HarnessContextBuilder(DefaultServerCallContextBuilder):
def __init__(self, resolver: ParticipantResolver | None = None) -> None: ...
def build(self, request: Request) -> ServerCallContext:
context = super().build(request)
if self._resolver is None: # today's behaviour (decision-003)
value = request.headers.get(PARTICIPANT_HEADER)
else:
context.state[STRICT_STATE_KEY] = True # the header is never read
value = self._resolver(request) # raises ⇒ 500, no fallback
if value:
context.state[PARTICIPANT_STATE_KEY] = value
return contextsender_of in executor.py reads STRICT_STATE_KEY. In strict mode the message's own participant_id claim is never trusted on its own: with no resolved participant the message is refused (no participant asserted). This refines R4.3. Task access is exactly as with no header (AccessPolicy denies). A message, though, cannot fall back to its self-claimed metadata. That fallback would be a header bypass, and abuse case 1 forbids it.
PublicUrl (R2.3, R2.4, R6)
public_url(base_url, prefix) -> str:
base_url path | prefix | Result |
|---|---|---|
/ or empty | none | base_url (today) |
/ or empty | /agents/a | base_url + /agents/a |
/agents/a | none or /agents/a | base_url |
/agents/a | /agents/b | ConfigError naming both |
A prefix is normalized: it gets a leading / and no trailing /, and / alone counts as no prefix. build_agent_card and Activities(push_base_url=…) take public_url. The card's interfaces are <public_url>/ for JSON-RPC and <public_url> for HTTP+JSON, so a client given the prefixed URL finds the card at <public_url>/.well-known/agent-card.json.
FastAPI — tiny_harness/fastapi.py (R3)
def harness_router(harness: Harness, *, tags: Sequence[str] | None = None) -> APIRouter:
"""The harness's routes as an APIRouter with the harness's lifespan.
Include it with ``app.include_router(router)``. The prefix is the harness's own."""- The module imports
fastapiat the top. With no FastAPI installed, it raisesImportError("tiny_harness.fastapi needs FastAPI: pip install 'tiny_harness[fastapi]'")(R3.4).pyproject.tomlgains[project.optional-dependencies] fastapi = ["a2a-sdk[fastapi]>=1.2.2"], and the dev group installs it, so tests and pyright see it. - Routes and OpenAPI (R3.3). The routes from
build_routesgo through a2a-sdk's publicadd_a2a_routes_to_fastapi, applied to a scratchFastAPI(). That adds the tags and request body schemas. The path, endpoint, methods andopenapi_extraof each resultingAPIRouteare re-added to anAPIRouter(prefix=harness.prefix, lifespan=…)withadd_api_route. That call is what applies the router prefix: a route appended torouter.routesdirectly keeps its bare path. The default tag isA2A: <agent id>, so five agents show as five groups. - The 503 gate and limits per route. A router cannot carry ASGI middleware. A custom route class's
appdoes not help either: a probe on FastAPI 0.143 showed thatinclude_routerno longer copies routes (it wraps the router in_IncludedRouter) and that it ignores an overriddenAPIRoute.app. So the router gets router-leveldependencies:Depends(gate)raises503while the harness is not started.Depends(limits)enforcesrate_limit_per_minuteand a declaredContent-Lengthovermax_request_bytes(413). It shares one limiter state across the harness's routes.- A body with no declared length is read by the endpoint wrapper through a bounded stream. The wrapper stops at
max_request_byteswith413, and the read body is cached on the request for the SDK. RouteOptions.limits=Falsedrops the second dependency and the bound.
- The prefix (R3.2). The router carries
harness.prefix. The developer includes it without a further prefix. The same check as the mount check runs on the first request, againstrequest.url.path, because an include prefix sets noroot_path. IF the path does not start with<base path><harness prefix>THEN the request answers500, an ERROR is logged once naming both paths, and every later request is refused. So the P of R3.2 is set once, on the harness, and a different include prefix fails closed. Requirement 2.3 then holds. - What the router does not carry. CORS is application-wide in Starlette, so the router cannot apply it. IF
[server] cors_originsis set THEN the router's lifespan logs a warning telling the developer to addCORSMiddlewareto their app. The same goes for/ui: FastAPI'sinclude_routerdrops aMount, soui_dirgets a warning and is not served. The docs say both. - The router's lifespan is
harness.lifespan. FastAPI 0.143 merges a router's lifespan ininclude_router(_merge_lifespan_context), as a probe confirmed. The extra pinsfastapi>=0.143, and the testing plan proves the merge.
AgentConfig and the task queue default — tiny_harness/config.py (R4.6, R5.3, R7.2)
class AgentConfig(_Strict):
"""Who this agent is (issue-36): its id on tasks, in the card and in Temporal."""
id: str = Field(default="tiny-harness", pattern=r"^[a-z0-9][a-z0-9-]{0,62}$")
name: str | None = None # card name; default: AgentDescription's
description: str | None = None # card description; default: AgentDescription's
class Settings(_Strict):
agent: AgentConfig = AgentConfig()
...
class TemporalConfig(_Strict):
task_queue: str | None = None # was "tiny-harness"
@property
def effective_task_queue(self) -> str: ... # set by Settings' validator to agent.id when None- Default agent. With no
[agent]section and notask_queue, the id and the queue are bothtiny-harness, as today (R7.2). With[agent] id = "billing"and no queue, the queue isbilling. So five configurations that differ only in[agent] idnever clash. runtime.AGENT_IDis removed. Every reader usessettings.agent.id:workflow_config,commands.py's worker banner, and the participant the executor adds. The participant id of the default agent staystiny-harness.[agent].idgoes into theTinyHarnessAgentsearch attribute, which already exists, and into every log line (see Observability).- Everything that read
settings.temporal.task_queuenow readseffective_task_queue: the worker, the executor, the heartbeat andcommands.py.
Claims — tiny_harness/service/claims.py (R5.3, R5.8)
This is a process-wide registry guarded by a threading.Lock. claim(agent_id, (namespace, queue), store_path) either reserves all three or raises HarnessConflictError, naming the clash and the agent that holds it. release frees them. The store path is resolved, so two spellings of one file clash too.
R5.8 offers two options: share a store safely, or refuse to share it. The design takes refuse. Records carry no agent column today, and the access policy's store fallback must not see another agent's tasks. The registry covers one process only; the deploy guide says that two processes must not be given the same agent id.
TemporalPool — tiny_harness/service/durable/pool.py (R1.3, R5.2, NFR startup)
acquire(settings, *, output) -> Client and release(key). Connections are reference-counted per key:
- Embedded: the key is
("embedded", resolved database_path). The first acquire entersEmbeddedTemporal(dev server and state lock). The last release stops it. To share one dev server, several configurations set the same[temporal.embedded] database_path. Without that, each agent has its own (the default path is beside each store). - Remote: the key is
(address, namespace, sha256(api_key) or ""). The hash keeps the key out of logs. - A client passed to
Harness(client=…)skips the pool and is never closed (R1.3).
temporal_client(settings) keeps its signature and is reimplemented as acquire and release.
Ownership checks — task_store.py, push.py (R5.4, abuse cases 2, 3)
TemporalTaskStoretakestask_queue.lookup(task_id)callshandle.describe()first. IFdescription.task_queue != task_queueTHEN it returnsNone, the same as an unknown id. Ownership never changes, so the result is cached in an LRU of 4,096 ids per store, and a known id costs no extra RPC. TheRPCErrorfallback to the store stays, and the store is the agent's own.- Every A2A task operation already goes through
task_store.get:GetTask,CancelTask(current_task),SubscribeToTask, the push-config operations, andSendMessagewith a task id (the SDK raisesTaskNotFoundErrorwhen the store returnsNone). So the one check covers R5.4. The testing plan proves each operation. ListTasksaddsAND TaskQueue = '<queue>'to the visibility query.TaskQueueis a built-in search attribute. The unfiltered fallback (the test server) comparesexecution.task_queue.PushReceivertakestask_queue. Beforeexecute_update("remote_push", …)it describes the stand-in workflow. IF its queue is not the receiver's THEN it returns401, the same refusal as a wrong token (abuse case 3). A stand-in is a child workflow and runs on its parent's queue by default (start_child_workflownames no queue), so a stand-in's queue is its agent's.running_task_workflows(client, task_queue)gains the sameTaskQueuefilter, so a heartbeat scans only its own agent's tasks (R5.6).
Heartbeat per agent (R5.6)
schedule_id(agent_id) returns tiny-harness-heartbeat for the default agent (R7: an existing deployment keeps its schedule), and tiny-harness-heartbeat-<agent id> for any other. The scheduled run's workflow id follows the same rule. HeartbeatTick already carries task_queue. ensure_heartbeat_schedule and delete_heartbeat_schedule take the agent id, and tiny-harness schedules delete passes settings.agent.id. Retention pruning runs over the agent's own store.
SecretRegistry — tiny_harness/harness/security/ (abuse case 6)
PROCESS_SECRETS is a process-wide set with a version counter. Harness.__aenter__ registers secret_values(settings) and __aexit__ removes them. Redactor gains an optional registry. When the registry's version changes, the redactor rebuilds its sorted value list. Every Redactor built by the service (the runtime's, the logging formatter's from observe) passes PROCESS_SECRETS. Agent A's logs, traces and saved messages then mask agent B's keys too. Push tokens are already never logged (issue-27), and that does not change.
Observability: the agent on every line and span (NFR)
A context variable current_agent is set:
- by
HarnessAppand the router'sgatedependency, for each request; - by a Temporal worker interceptor,
AgentContextInterceptor(agent_id), for each activity.
JsonFormatter adds "agent": <id> when the variable is set. The o11y hook adds the span attribute tiny_harness.agent. Workflow code is sandboxed and does not log through this formatter. Its records already carry the task queue, which equals the agent id by default.
running_harness, serve and the CLI (R7)
@asynccontextmanager
async def running_harness(settings, *, with_worker=True, log_stream=None, plugins=(),
commands=(), builtin_commands=True) -> AsyncGenerator[RunningHarness]:
harness = Harness(settings, with_worker=with_worker, log_stream=log_stream,
plugins=plugins, commands=commands, builtin_commands=builtin_commands)
async with harness:
server = uvicorn.Server(uvicorn.Config(harness.asgi_app(), …)) # as today
...
yield RunningHarness(harness.public_url + "/", harness.client, settings, task)The signature, the yielded RunningHarness and the embedded-mode rule (with_worker forced on) are unchanged. serve is unchanged on top of it. tiny-harness worker builds through the same runtime path with effective_task_queue and the per-agent schedule id.
Example — examples/multi_agent/ (R6.4)
a.toml…e.toml: five agents,[agent] id = "a"…"e", one shared[temporal.embedded] database_path, and their own stores. Each setsbase_url = "http://127.0.0.1:8090/agents/<id>". Agentalistsbandcunder[[agents]], andblistsd.app.py: one Starlette app that mounts all five withharnesses_lifespan, plus a FastAPI variant (app_fastapi.py) for the router path.README.md: run it, then askasomething that it delegates tob.
UI/UX design
N/A. This is an SDK and server API change with no screen. The web renderer and the TUI are unchanged. They reach a mounted agent by its prefixed URL.
Data models
- Configuration: a new
[agent]section (id,name,description).[temporal] task_queuebecomes optional and defaults toagent.id. Configuration files valid today load unchanged and give the same id and queue (R7.2). - Persistence: no schema change. Stores are per agent, by the
Claimsrule. - Temporal: no workflow or activity signature changes, so no replay risk. The
HeartbeatTickmodel is unchanged. Only the schedule id of a non-default agent is new. - Agent card: for the default agent at the root, the card is byte-identical, and the contract snapshot is unchanged (R7.3).
[agent] nameanddescriptionset the card'snameanddescriptionwhen given.
Error handling
| Failure | When | Surface |
|---|---|---|
Prefix disagrees with base_url path | Harness(...) | ConfigError naming both values |
| Duplicate agent id / namespace+queue / store path | __aenter__, before any connection | HarnessConflictError naming the clash and the holder |
| Second start, or a start after stop | __aenter__ | HarnessAlreadyStartedError, HarnessStoppedError |
harness.client before start | property access | HarnessNotStartedError |
| Request before start | each request | 503 JSON, logged at DEBUG (it is expected during startup) |
| Mounted at the wrong path | first request | 500 JSON, ERROR logged once, then every request refused |
| Router included under another prefix | first request | 500 JSON, ERROR logged once naming both paths, then every request refused |
| Resolver raises | request | 500; the exception is logged with the agent id; never a fallback to the header |
| Temporal unreachable at start | __aenter__ | the existing temporal_client errors; the exit stack undoes earlier steps |
cors_origins / ui_dir set in router mode | router lifespan | WARNING naming the setting and what to do |
| FastAPI missing | import tiny_harness.fastapi | ImportError naming the extra |
All new errors subclass TinyHarnessError in tiny_harness/errors.py. The CLI already prints those without a traceback.
Security design
- AuthN/AuthZ.
- No resolver (today's mode). Identity is the
X-Participant-Idheader set by a gateway (decision-003). This does not change. - With a resolver.
HarnessContextBuildernever reads the header. It setsSTRICT_STATE_KEY, andsender_ofrefuses a message that has no resolved participant instead of trusting the message's own claim. - Authorization. It stays in
AccessPolicy, applied throughtask_store.get. The ownership check adds the agent boundary in front of it: queue, then participant.
- No resolver (today's mode). Identity is the
- Input validation & injection surfaces.
- Task ids. A task id from a client reaches
get_workflow_handle(id).describe(), a typed RPC with no query language, as today'squerycall does. - The
ListTasksquery. The newTaskQueue = '<queue>'clause is built from configuration, not from the request.context_idis still interpolated, as today. It is escaped (single quotes doubled), which closes an injection that exists today. - The prefix. It comes from configuration or code, is normalized and is compared, never evaluated.
- The resolver. It is the host's own code and is trusted.
- Task ids. A task id from a client reaches
- Secrets handling.
- Process-wide masking.
SecretRegistrymakes every redactor mask every agent's model key, Temporal key and Langfuse keys. - The pool key.
TemporalPoolkeys hold a SHA-256 of the API key, never the key. - Push tokens. They stay digest-only in workflows and absent from logs.
- Process-wide masking.
- Least privilege.
- Workers. Each worker polls only its own queue, so it runs only its own agent's workflows and activities.
- Stores. Each runtime opens only its own store file.
- The FastAPI router. It gets the same handler and no more.
- Fail-closed behaviour.
- Configuration clashes. An unknown or clashing configuration stops startup before any connection or worker.
- Ownership. A task whose queue cannot be confirmed is not found: a
describethat fails falls back to the agent's own store and never to another agent's. - Pushes. A push whose stand-in is not ours is
401. - Before start. A request before start is
503. - Wrong mount. A wrong mount is
500until fixed. - No limits. With
limits=False,RouteOptionslogs at start that the host must limit request size and rate. The docs say the same next to the option.
- Abuse-case coverage (requirements § Security considerations):
| # | Abuse case | Mechanism | Negative test |
|---|---|---|---|
| 1 | Header spoofing with a resolver | HarnessContextBuilder ignores the header; strict sender_of | Resolver returns alice, client sends X-Participant-Id: admin → task created with reporter alice; admin's task unreadable |
| 2 | Cross-agent task access | Queue ownership check in lookup; TaskQueue filter in ListTasks | Task on agent b; each of GetTask, ListTasks, CancelTask, SubscribeToTask, push-config get/set/list/delete, SendMessage with its id, on agent a → not found, b's task unchanged |
| 3 | Push to the wrong agent | Queue check in PushReceiver before the update | b's stand-in id with b's valid token at a's push route → 401, b's stand-in not woken |
| 4 | Shared queue | Claims | Two configs with the same namespace and queue → second start raises HarnessConflictError, no worker registered |
| 5 | Request before start | HarnessApp 503 gate; Depends(gate) in router mode | Request before lifespan → 503; a Temporal client double records no call |
| 6 | Cross-agent secret leak | SecretRegistry in every redactor | Agent a logs a string that holds b's key → the log line holds the mask |
Testing strategy
The core changes get unit tests: public_url, Claims, TemporalPool reference counting, the RouteOptions toggles, the resolver and strict sender_of, the ownership check with a fake client, SecretRegistry versioning, the schedule id and the AgentConfig defaults. Integration tests run on the local Temporal server (start_local, as since PR #28):
- one harness mounted under a prefix in a host Starlette app, with the card URLs, the 503 window and the wrong-mount refusal;
- the FastAPI router, with the lifespan merge, the OpenAPI tags, the 503 and limits dependencies, and the prefix-mismatch refusal;
- two agents in one process for every abuse case above;
running_harnessandserveunchanged, with the existing tests unmodified and the card contract snapshot unchanged.
The acceptance scenario is Scenario: five agents in one app delegate to each other. It runs examples/multi_agent/ in-process with a scripted model, and agent a delegates to b over HTTP and gets the result, with a push back to a's prefixed route. Each integration test carries a Gherkin docstring linking its requirement. The testing types, the environment and the evidence are in testing-plan.md.
Trade-offs & decisions
- Ownership by task queue, not by namespaced workflow ids. Prefixing workflow ids with the agent id would isolate by construction with no extra RPC. But it would change every handle lookup in
workflows.py,activities.py, the bridge and the push receiver, and break running workflows of existing deployments. Adescribeplus an LRU costs one RPC per new task id per process and touches three call sites. To be logged as decision-009. - Refuse shared stores rather than partition them. Adding an agent column to every record kind is a migration for little gain. Each agent gets its own SQLite file. Also decision-009.
- The prefix lives on the harness, not on
include_router. The card is built once at start and must know its URL before any request arrives. A router-level prefix is known only afterinclude_router. So the harness owns the prefix, and the first request verifies the router was not given another. - Strict participant mode with a resolver. This goes slightly beyond R4.3's wording. Without it, a message's self-claimed
participant_idwould bypass the host's auth (abuse case 1). It is called out here for the reviewer. - Routes built at definition time over a deferred handler. This makes R2.2 (mount first, 503 until started) and R3.3 (static routes for OpenAPI) possible with one route set. The cost is a small proxy class over the SDK's
RequestHandlerinterface. - No in-process transport between agents (R6.3). Calls loop back over HTTP to the same server. That costs a local hop, and keeps one code path and one access check.
- CORS and
/uiare not carried by the FastAPI router. Starlette applies CORS application-wide, and FastAPI drops mounts from routers. The harness warns, and the docs say what to add.
Open questions
- Credentials for agent-to-agent calls when the host requires auth. Agent a calls b with
X-Participant-Id: aand no other credential. If the host's resolver needs a bearer token, a's call is refused. The example and the acceptance test use the default header mode. Aheadersoption on[[agents]](values from the environment) would close this; it is proposed as a follow-up issue, not built here. Raised on PR #37.
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109). Append-only and attributed: an approval never silently discards a reviewer's suggestions, and the feedback travels with the document it concerns rather than living in a side-channel tracker.
2026-10-13 — approved
@MadaraUchiha-314 wrote:
approved