Skip to content

API reference

The public surface is seven import paths: the package and six submodules. Everything else in the package is internal, and a name outside these pages may change without a deprecation.

Import path Holds Imported by
blackboard The board, the control component, create_model, the AgentBoard an agent body is written against, and the scheduled work: close_expired, relay_unsent and Sweep The blackboard and the agent
blackboard.wire The request and response bodies that cross between the two halves, and the seven operations that carry them Both
blackboard.server Answering an agent's request, without a web framework The blackboard
blackboard.delivery Sending notifications to agents over HTTP The blackboard
blackboard.agent Reading and writing a board from an agent The agent
blackboard.tools Each method of AgentBoard as a tool a language model can call, for the application to choose among The agent
blackboard.conformance The suite every store implementation is held to Anyone writing a store

blackboard

blackboard

A skeletal blackboard system.

The library supplies the board, the shared structure through which independent agents contribute to one result, and the control component, which determines which agents are notified of a change, which proposed writes are admitted, when the run's limits still hold, and when the run has finished. An application creates a model by supplying its regions, their opening premise values, the agents the run starts with, an admission rule, a termination predicate, and limits. The public surface is the set of names in __all__; every other name is internal.

AdmissionRule module-attribute

AdmissionRule: TypeAlias = Callable[
    [ProposedWrite, "BoardReader"], Accept | Reject
]

The rule the control component calls on every proposed write.

NotificationId module-attribute

NotificationId = NewType('NotificationId', int)

The identifier an acknowledgment names.

ProposedWrite module-attribute

ProposedWrite: TypeAlias = (
    ProposedContribution | ProposedPremiseWrite
)

A proposed write, level or premise, as the admission rule receives it.

RunOutcome module-attribute

RunOutcome: TypeAlias = Settled | WallClockExpired | Aborted

The three states a run closes in.

Each names the agents that did not finish, meaning those holding an unacknowledged notification. Why a run ended and which agents failed to finish are separate facts, and a run settles normally while one agent never returns.

TerminationPredicate module-attribute

TerminationPredicate: TypeAlias = Callable[
    ["BoardReader"], TerminationDecision
]

The predicate the control component calls when no work is outstanding.

AgentProgress dataclass

How far one agent has been told, and how far it has answered.

Both are sequence numbers on this board, so they are comparable with a contribution's sequence and with each other. An agent is unfinished when it has been told further than it has answered, which is what any process reads to name the agents a closing run did not hear back from.

Both only ever rise. Two processes notifying one agent leave the higher of what they wrote, so neither can undo the other and no lock is needed to keep them ordered.

outstanding property

outstanding: bool

True while the agent owes an answer for what it was told.

BlackboardError

Bases: Exception

The base of every error this library raises.

BoardChange dataclass

One write to any region, at its position in the total order.

writer and written_at follow the same rules as on :class:Contribution.

Conflict dataclass

A premise write that failed because the version it named is not current.

Contribution dataclass

One unit stored in a level, at its position in the total order.

writer is the name the write carried and written_at is the store's clock when it landed. Both are None on a record written before the library recorded them, and writer is None for an opening premise value, which the application supplied rather than any agent.

Deleted dataclass

What removing one board took with it.

A board the store never held names nothing rather than failing, so deleting one twice is safe. Both fields are counts, and are named as counts because regions elsewhere on this surface holds names.

DuplicateRegionError

Bases: BlackboardError

A declaration named a region that already exists.

IdempotencyKeyError

Bases: BlackboardError

An idempotency key was used for a region it did not name before.

A key names one write. Sending it again for a different region is a caller's mistake rather than a retry, and the store refuses it instead of silently answering with the write it does name.

InMemoryStore

A store held in process memory. A test double, not a way to run a deployment.

Nothing it holds outlives the process, and two processes running the same code share nothing. An application keeps its record in a database through an adapter; a test that wants no file uses this.

Content is carried as JSON, as it is in every store that crosses a process boundary. Holding a Python object as it stands would make this store accept what a deployment then refuses, and preserve types, such as a tuple, that no other store returns. Content that JSON cannot carry raises TypeError before anything is stored.

Sequence assignment is the only point where two writes to one board wait on each other, so writes from concurrent threads all succeed with distinct sequence numbers.

declare

declare(board_id: str, region: Level | Premise) -> None

Creates a region on one board. A name already declared is refused.

append

append(
    board_id: str,
    level: str,
    content: object,
    idempotency_key: str | None = None,
    writer: str | None = None,
    notify: frozenset[str] = frozenset(),
) -> Written

Adds one contribution to a level and returns where it landed.

set

set(
    board_id: str,
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
    writer: str | None = None,
    notify: frozenset[str] = frozenset(),
) -> Written | Conflict

Replaces a premise's value under the version the caller expects.

A write naming any version other than the premise's current one fails: it returns a conflict carrying the current version, takes no sequence number, and leaves the premise unchanged. A premise never written has version 0. A conflict writes nothing, so it uses up no key.

read_level

read_level(
    board_id: str,
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

read_premise

read_premise(board_id: str, premise: str) -> PremiseState

Returns a premise's current value and version.

read_board

read_board(
    board_id: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

delete

delete(board_id: str) -> Deleted

Removes one board's regions, record, premise values, and counter.

The application decides when a board is deleted; nothing in the library calls this. Close the run first: a Control still serving this board goes on writing to a record that is no longer there.

read_regions

read_regions(board_id: str) -> list[Level | Premise]

Returns the regions declared on one board, with their kinds.

Level dataclass

A declaration of a region that accumulates contributions in arrival order.

The control component reads the batch window when a change to this level lands, to schedule its notification. The board ignores it. A window collapses a burst of writes into one notification, which costs a subscriber one wake instead of one per write.

Premise dataclass

A declaration of a region that holds one current value under a version.

The control component reads the batch window when a change to this premise lands, to schedule its notification. The board ignores it.

PremiseState dataclass

The current value of a premise and the version its write produced.

writer and written_at describe the write that set the current value, and are None where the record predates them or the value is the opening one.

RegionKindError

Bases: BlackboardError

An operation that takes a level named a premise, or the reverse.

RunRecord dataclass

What the store holds about one board's run.

now is the store's clock at the moment of the read, so a caller decides that a deadline has passed by comparing two instants that came from the same clock. Pods disagree about the time and the store does not.

closed_as is None while the run is open, and otherwise names the outcome: settled, wall_clock_expired or aborted.

expired property

expired: str | None

Names the limit that has passed, or nothing while both hold.

UndeclaredRegionError

Bases: BlackboardError

An operation named a region that no declaration created.

Unsent dataclass

One notification a write recorded and nothing has sent yet.

The row exists because the intent to notify is written in the same transaction as the contribution, so a process that commits a write and stops before delivering has not lost it. Whoever holds the agent sends it and marks it sent.

through is the sequence the notification's range ends at, which is also the identifier it carries.

UnsetPremiseError

Bases: BlackboardError

A premise was read before any write gave it a value.

Written dataclass

A write the board sequenced, at the sequence number it received.

version is the premise's new revision count. A level write leaves it None, because a level holds no version to replace.

repeated says the store had already written this idempotency key and answered with what that write produced. Nothing was added.

Clock

Bases: Protocol

Reads the current instant and arms calls to fire at chosen instants.

now

now() -> datetime

Returns the current instant, timezone-aware in UTC.

call_at

call_at(
    when: datetime, call: Callable[[], None]
) -> ScheduledCall

Arms a call to fire at an instant and returns its cancellation handle.

ManualClock

A deterministic clock for tests; time moves only through advance.

advance

advance(delta: timedelta) -> None

Moves time forward and fires every due call on the calling thread.

Calls fire in due order; ties fire in arming order. A call armed during the advance fires inside it when its instant falls at or before the target. A call armed at or before the current instant fires on the next advance, a zero advance included. A fired call may itself advance the clock; the outer advance never moves time back below where an inner one left it.

ScheduledCall

Bases: Protocol

A call armed on a clock, cancellable until it fires.

cancel

cancel() -> None

Prevents the call from firing. Cancelling a fired call changes nothing.

SystemClock

The default clock, backed by the operating system.

Aborted dataclass

A caller closed the run.

Accept dataclass

The admission rule's verdict admitting a proposed write.

Agent dataclass

An agent declaration: identity, delivery, and what it wants.

subscribes_to names the regions, level or premise, whose changes wake this agent, and naming any region excludes every region not named. Omitting it subscribes the agent to every premise and to no level, which is the common case: a premise holds something the work was given, which bears on any agent's work, while another agent's conclusion does not. writes_to names the levels the agent may write to, and omitting it permits every level.

address is where the agent is reached, and is written to the run so that any process serving the board knows it. notify is how it is reached from this process, and is written nowhere, because a function is not data. A declaration carries at least one of the two: notify alone is an agent living in this process, address alone is one reached over the wire, and both is an address on the record with a faster path where the callable is present.

An agent declared by address alone is delivered to by the transport the process was given as reach, on the same paths a callable is delivered to on: when it joins, when a write it subscribes to lands, and when a batch window closes. A process given no transport reaches it not at all, and leaves the row a write recorded for one that was.

The control component invokes notify to deliver a notification, holding no lock, on the thread that closed the batch window or, when deliveries chain, on a thread already draining them; two notifications to one agent can therefore arrive on two threads at once. The callback may run the whole agent cycle inline or hand the notification to the application's own execution. A callback that blocks on wait_closed holds the run open, because its own unacknowledged notification counts as outstanding work.

Both subscribes_to and writes_to are read on every write and every notification, so each is kept as a frozenset of what was given. A declaration built from a generator therefore behaves like one built from a set, rather than emptying itself on first use.

AgentBoard

Bases: Protocol

One board, as one agent sees it: what an agent body is written against.

BoardClient satisfies this over HTTP and Control.as_agent returns one in process, so a body written once serves both deployments. Every method omits the agent's own name, because the object already carries it.

Reads are the four operations that BoardReader has. Writes are the three that Control has, without the identity argument.

board_id property

board_id: str

The board this object reads and writes.

read_regions

read_regions() -> list[Level | Premise]

Returns the regions declared on this board, with their kinds.

read_level

read_level(
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

read_premise

read_premise(premise: str) -> PremiseState

Returns a premise's current value and version.

read_board

read_board(
    from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

write

write(
    level: str,
    content: object,
    idempotency_key: str | None = None,
) -> Written | Rejected

Proposes a contribution to a level, as this object's agent.

set_premise

set_premise(
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
) -> Written | Conflict | Rejected

Sets a premise, provided it is still at expected_version.

ack

ack(notification_id: NotificationId | int) -> None

Records that this agent finished responding to a notification.

BoardReader

Bases: Protocol

The four read operations, the handle the admission rule receives.

read_level

read_level(
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

read_premise

read_premise(premise: str) -> PremiseState

Returns a premise's current value and version.

read_board

read_board(
    from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

read_regions

read_regions() -> list[Level | Premise]

Returns the regions declared on this board, with their kinds.

BoardStore

Bases: Protocol

The operations the control component performs on a store.

A store holds many boards. Every call that acts on one names it first, so one connection to a database serves every board an application runs. Two sweep for work instead and name none: runs_past_deadline answers what needs closing and unsent what needs sending, over every board the store holds.

InMemoryStore and SqliteStore satisfy this protocol, as does any adapter an application writes against its own database. The control component names no concrete type, and holds every implementation to one conformance suite.

Content crosses this protocol as JSON. An implementation returns what JSON carries, so a tuple that is written comes back as a list, and content that JSON cannot carry raises TypeError before anything is stored.

declare

declare(board_id: str, region: Level | Premise) -> None

Creates a region on one board.

append

append(
    board_id: str,
    level: str,
    content: object,
    idempotency_key: str | None = None,
    writer: str | None = None,
    notify: frozenset[str] = frozenset(),
) -> Written

Adds one contribution to a level and returns where it landed.

notify names the agents that should hear of this write. One row per name is recorded in the same transaction as the contribution, so a process that commits and stops before delivering has not lost the intent. A write that is a repeat records none, because nothing happened.

writer is the name the write carried, recorded on the row so the record answers who wrote it. The store stamps the instant itself.

idempotency_key names one write on one board. A key that the store has already written answers with what that write produced, marked repeated, and adds nothing. A key that named a different region raises IdempotencyKeyError, because that is a mistake rather than a retry. Without a key nothing is deduplicated.

set

set(
    board_id: str,
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
    writer: str | None = None,
    notify: frozenset[str] = frozenset(),
) -> Written | Conflict

Replaces a premise's value under the version the caller expects.

writer is recorded as it is on append.

idempotency_key works as it does for append. A conflict writes nothing, so it uses up no key.

read_level

read_level(
    board_id: str,
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

limit caps how many come back. A caller continues from one past the last sequence it received, which an offset could not do, because an offset shifts when a concurrent write lands, and a sequence number does not.

read_premise

read_premise(board_id: str, premise: str) -> PremiseState

Returns a premise's current value and version.

read_board

read_board(
    board_id: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

delete

delete(board_id: str) -> Deleted

Removes one board's regions, record, premise values, and counter.

Delete removes everything or nothing: a caller that gets an answer got a board that is gone. A board that the store never held names nothing rather than failing, so a delete that runs twice is safe.

Nothing in the library calls this. Deleting is a retention decision and the control component makes none.

open_run

open_run(
    board_id: str, *, wall_clock: float, idle: float
) -> None

Records that a run is open over this board, with its two limits.

Both limits are seconds. The store computes the two deadlines from its own clock, so no instant crosses the call and no caller's clock decides when a run ends.

read_run

read_run(board_id: str) -> RunRecord | None

Returns the run over this board, or nothing where none was opened.

The record carries the store's clock alongside the deadlines, so a caller compares two instants that came from one clock.

touch_run

touch_run(board_id: str, *, idle: float) -> None

Pushes the idle deadline out by idle seconds from now.

The wall clock is left where it was, because it bounds the whole run rather than the quiet in it. A run already closed is unchanged.

close_run

close_run(
    board_id: str,
    *,
    closed_as: str,
    reason: str | None = None,
    unfinished: frozenset[str] = frozenset(),
) -> bool

Records how the run ended, and says which caller recorded it.

Answers True to the one caller that closed the run and False to every other, so a run closes once however many callers reach the deadline together. That is what makes closing safe without a lock.

runs_past_deadline

runs_past_deadline(limit: int = 100) -> list[str]

Returns boards whose run is open and past one of its deadlines.

A run closes because nothing happened, so no request is in flight to notice. This is what a caller polls to close those runs.

unsent

unsent(limit: int = 100) -> list[Unsent]

Returns notifications recorded by a write and not yet sent.

Across every board, oldest first by the sequence the notification ends at, so the process that reads them sends the earliest work first. A caller sends what it can reach: an agent it holds a callable for, or one the run records an address for, which is any of them.

mark_sent

mark_sent(
    board_id: str, agent: str, *, through: int
) -> None

Records that this agent was sent everything through through.

Removes every row for this agent on this board at or below that sequence, because a notification covers a range and sending it answers every row inside it. That is the same cumulative rule acknowledgment follows.

A row that never existed changes nothing. Marking after sending rather than before is what makes delivery at least once: a process that sends and stops before marking sends again, and a repeat costs nothing.

read_agents

read_agents(board_id: str) -> list[AgentProgress]

Returns how far each agent on this board has been told and answered.

One entry per agent the board has notified, in any order. A board that has notified nobody returns nothing rather than refusing.

Each entry is internally consistent, so acknowledged_through never exceeds notified_through on a row that comes back. The entries are not read as one snapshot across agents, and nothing needs them to be.

declare_agent

declare_agent(
    board_id: str,
    agent: str,
    *,
    subscribes_to: frozenset[str] | None = None,
    writes_to: frozenset[str] | None = None,
    address: str | None = None,
) -> None

Records that this agent is part of this run, and what it wants.

Called for every agent a board starts with and for every one that joins a run already under way, because those are two doors to one fact. Declaring the same agent again replaces what it declared, which is what an agent that restarted and changed its mind needs.

address is where the agent is reached, and is None for one that lives in the process that declared it. No adapter holds the callable that reaches it; that is not data.

Creates the entry where none exists, leaving how far the agent has got where it was.

mark_notified

mark_notified(
    board_id: str, agent: str, *, through: int
) -> None

Records that the agent has been told everything through through.

Creates the entry where none exists. A through below what is already recorded changes nothing, so two processes notifying one agent leave the higher of the two whichever order they arrive in. That is what makes notifying from more than one process safe without a lock.

acknowledge

acknowledge(
    board_id: str, agent: str, *, through: int
) -> AgentProgress | None

Records the agent's answer, and returns the entry as it stood before.

Answers None where the agent has no entry, and where through is beyond what the agent was told. Both mean the acknowledgment names a range the store never handed out.

acknowledged_through only rises, so of several callers naming one through exactly one is answered with an entry below it. That is how a first acknowledgment is told from a repeat without a second read.

read_regions

read_regions(board_id: str) -> list[Level | Premise]

Returns the regions declared on one board, with their kinds.

A store records a region's name and its kind, and nothing else. A premise comes back with the default batch window, whatever window it was declared with, because the window tells the control component when to notify and is no part of the record.

A board that nobody declared a region on returns nothing rather than refusing, because a board comes into being by being written to.

Control

The control component's write path, over the board it is given.

The board holds the record and outlives this object. The run is in the store beside it, being its two deadlines, its outcome, its agents, how far each has been told and has answered, and what a write recorded and nothing has sent. So this object holds nothing but the callables it was given and the batch windows it has armed.

board_id property

board_id: str

The board this control component runs over.

reader property

reader: BoardReader

The board's read side. Reads bypass the control component entirely.

as_agent

as_agent(name: str) -> AgentBoard

Returns this board as one agent sees it.

The object carries the agent's name, so its methods are the ones BoardClient has and an agent body written against AgentBoard runs in this process or against a blackboard over HTTP.

The agent need not be registered. Registering decides what wakes it; this call decides what it writes as.

declare

declare(region: Level | Premise) -> None

Creates a region on the board and records its kind.

register_agent

register_agent(agent: Agent) -> None

Registers an agent and wakes it.

The agent is out of date with everything already on the board, so registering issues one notification covering the regions it subscribes to that already hold something. Its cursor starts at zero, since it has read nothing.

Registering a name that is already registered replaces that agent's declaration, including its callback and its subscriptions, which is what an agent that restarted or moved needs. Its cursor survives, because it has not forgotten what it acknowledged. Whatever notification it was still holding is discarded, and the one issued here covers everything since that cursor, because a notification carries no values.

write

write(
    level: str,
    content: object,
    idempotency_key: str | None = None,
    *,
    writer: str,
) -> Written | Rejected

Runs one level write through admission and, if admitted, the board.

idempotency_key names one write. A key already written answers with what that write produced, marked repeated, and changes nothing: no intent to notify, no notification, and no push of the idle deadline, because nothing happened. A key that named a different region raises IdempotencyKeyError, because the caller chose it.

set_premise

set_premise(
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
    *,
    writer: str,
) -> Written | Conflict | Rejected

Runs one premise write through admission and, if admitted, the board.

idempotency_key works as it does for write. A conflict stores nothing, so it uses up no key.

abort

abort(reason: str) -> None

Closes the run as aborted. A run already closed keeps its outcome.

outcome

outcome() -> RunOutcome | None

Returns the closed run's outcome, or nothing while the run is open.

wait_closed

wait_closed(
    timeout: timedelta | None = None,
) -> RunOutcome | None

Blocks until the run closes and returns its outcome.

With a timeout, returns nothing when the run is still open after it.

ack

ack(
    notification_id: NotificationId | int, *, agent: str
) -> None

Records that the agent finished responding to a notification.

The cursor advances to the end of the range the notification covered, and every notification to this agent whose range ends at or before that one is acknowledged with it, because the cursor is cumulative and answering the wider range answered the narrower ones.

An acknowledgment of a notification no longer outstanding changes nothing; an acknowledgment that names a notification never issued to that agent raises an error.

relay

relay() -> list[str]

Sends what a write recorded and nothing has sent, and names them.

A process that took a write and stopped before delivering left the intent on the record. This sends it.

An agent this process declared is reached the way it was declared, through its callable or through the address the run records. One this process never saw declared is reached through that address alone, if this process was given a transport; without one, such a row is left for a process that has either.

A send that raises leaves the row for the next pass, so nothing is marked that was not sent. Call it on whatever schedule suits the deployment, beside :func:close_expired.

An agent whose callable queues rather than sends, which is what a lane does, is named once the notification is handed over. That is the most this can know: the row it leaves is cleared by the lane, after a send this call has already returned from.

notify_due

notify_due() -> list[str]

Delivers what this process's agents are owed, and names them.

A write taken by one process reaches the agents declared with another here. That is what an agent declared by callback needs: the process holding the callback is the only one that can call it, so it is the one that reads the record and decides. An agent the run records an address for is reached by whichever process took the write, and does not wait for this.

A run inside one process never needs this. That process notifies on the write path and closes its own windows on a timer, so this finds nothing to do. Call it on whatever schedule suits the deployment, beside close_expired.

DuplicateAgentError

Bases: BlackboardError

One roster named the same agent twice.

A roster is one list written at one moment, so a repeat in it is a mistake. Registering a name again later names a returning agent rather than a duplicate, and replaces that agent's declaration.

Notification dataclass

One notification: the range it covers and the regions that changed.

It carries no values. The agent reads the board itself, over any range and any region, not only the regions this notification names.

PremiseError

Bases: BlackboardError

The opening premises are not exactly the premises that were declared.

ProposedContribution dataclass

A level write as the admission rule sees it, before sequencing.

ProposedPremiseWrite dataclass

A premise write as the admission rule sees it, before sequencing.

Reject dataclass

The admission rule's verdict refusing a proposed write, with its reason.

Rejected dataclass

A write the control component refused, with the cause and its reason.

RejectionCause

Bases: Enum

Why the control component refused a write.

Every cause is a decision the run made about a write it understood. What the application's own declarations settle, such as a region nobody declared, raises an error instead.

ADMISSION: the admission rule rejected it. NOT_PERMITTED: the writing agent did not declare that level. RUN_CLOSED: the run has closed.

RunClosedError

Bases: BlackboardError

A declaration or registration reached a run that has closed.

RunLimits dataclass

The two limits on a run, both durations, both required.

Time is the only bound. A count of writes would limit the cause of a notification and a count of notifications would limit the effect, and limiting the effect can leave a change that no agent hears while the run is still open, which ends the shared record without closing the run.

Settled dataclass

Nothing happened for the idle limit, so the run closed.

Sweep

Calls :func:close_expired on an interval, on a thread of its own.

A convenience, the way HttpNotifier is a convenience over the wire protocol. The library ships the mechanism and the application owns the loop: a scheduled job, a serverless invocation, or a thread beside the service are all equally supported, and this is only the last of those written out so an application does not have to.

control_for is what makes a pass relay as well as reap. It answers with the Control for a board, and is the same callable BoardService takes. A sweep given none reaps and nothing more, which is what a sweep did before this argument existed.

interval is the seconds between passes. jitter spreads the first pass over a fraction of that interval, chosen once when the sweep starts, so replicas that started together do not query together for ever after. Pass jitter=0.0 for a test that wants the first pass promptly.

A pass that raises is logged and the loop continues. A loop that died on one failure would leave every later run open, which is the failure the sweep exists to prevent.

running property

running: bool

True between start and close.

start

start() -> None

Starts the thread. Starting a running sweep does nothing.

close

close() -> None

Stops the thread and waits for the pass in flight to finish.

wait_for_pass

wait_for_pass(timeout: float, passes: int = 1) -> bool

Blocks until this many passes have run. True if they did.

For a test that needs a pass to have happened before it asserts.

TerminationDecision

Bases: Enum

The termination predicate's answer when no work is outstanding.

UnknownNotificationError

Bases: BlackboardError

The named notification was never issued to the acknowledging agent.

WallClockExpired dataclass

The wall clock limit passed while the run was open.

Model dataclass

A handle to a board that lives in the store: its reader and its control.

It holds nothing. Every call reads what it needs from the store, so holding one keeps no run open, caches no registry, and reserves nothing. Build one wherever it is convenient, on any process, and discard it; the board is unaffected either way. Dropping the last one closes nothing, because a run ends on its deadlines or on control.abort and on nothing else.

The name invites the opposite reading, so it is said plainly here. The same shape appears elsewhere: a boto3 Bucket is a handle to something living in S3 rather than a copy of it, and a LangGraph graph is stateless with the thread identifier travelling in the config on every call.

Board reads go to reader directly, consume no control capacity, and cannot be refused. Writes, acknowledgments, and lifecycle calls go through control.

reader is control.reader, and is a field of its own so a component that only reads takes the reader alone. board_id names the board, so a registry keyed by it does not have to repeat the string the caller passed.

SchemaVersionError

Bases: BlackboardError

A record was written by a schema this version of the library cannot read.

Upgrade blackboardx to a version that reads it. The library never rewrites a record backwards, because an older version would then read fields a newer one wrote and take them at face value.

SqliteStore

Keeps the board in SQLite. Satisfies BoardStore.

path names the database file and has no default, because where the record is kept is stated rather than defaulted. A path on disk suits local development, where the record outlives the run that made it. ":memory:" keeps it in the process, which suits a test and nothing else: a second store over ":memory:" in the same process shares nothing with the first, so it reads an empty board.

One file holds many boards. Every call that acts on one names it, and two boards under different identifiers share the file and see none of each other's writes, sequence numbers included. The two that sweep for work read across boards by design, and answer what is owed rather than what is on any board.

The schema is created on construction, because SQLite has no server to migrate separately and the file is the application's own.

schema_sql classmethod

schema_sql() -> str

Returns the statements this store runs when it opens a file.

For an application that would rather create the tables itself. Every statement is conditional, so applying them twice changes nothing.

close

close() -> None

Closes the connection. Reopening the same path reads the record back.

read_regions

read_regions(board_id: str) -> list[Level | Premise]

Returns the regions declared on one board, with their kinds.

MongoStore

Keeps the board in MongoDB. Satisfies BoardStore.

database is the application's own pymongo.database.Database, and this adapter does not open or close the client behind it. One database holds many boards. Every call that acts on one names it, and two boards under different identifiers share the collections and see none of each other's writes. The two that sweep for work read across boards by design, and answer what is owed rather than what is on any board.

Requires the mongodb extra::

pip install 'blackboardx[mongodb]'

Requires a replica set or a sharded cluster, because every write spans two documents and is therefore a transaction. A standalone server raises on the first write, naming the reason.

Content is stored as a document rather than as encoded text, so the record is queryable in the database that was chosen for querying it. It goes through JSON on the way in, so what the board holds is what every other implementation holds.

Call :meth:create_indexes once, or create the equivalent indexes from whatever migration the application already runs.

from_uri classmethod

from_uri(
    uri: str, database: str, **client_kwargs: Any
) -> Iterator[MongoStore]

Opens a client for the duration of a with block, for a script or test.

An application that already runs a client passes its database to the constructor instead. This is for the callers that have no client to pass.

create_indexes

create_indexes() -> None

Creates the indexes this adapter reads by, if they are not there.

Creating an index that exists changes nothing, so calling it against a database that already has them is not an error.

PostgresStore

Keeps the board in Postgres. Satisfies BoardStore.

pool is the application's own connection pool, and this adapter does not open it or close it. Every call names the board within the database; two boards under different identifiers share the tables and see none of each other's writes.

Requires the postgres extra::

pip install 'blackboardx[postgres]'

Call :meth:create_schema once against a database that has none, or run the equivalent DDL from whatever migration tool the application already uses.

from_dsn classmethod

from_dsn(
    dsn: str, **pool_kwargs: Any
) -> Iterator[PostgresStore]

Opens a pool for the duration of a with block, for a script or test.

An application that already runs a pool passes it to the constructor instead. This is for the callers that have no pool to pass.

schema_sql classmethod

schema_sql() -> str

Returns the statements :meth:create_schema runs, without running them.

For an application whose database is owned by a migration tool, or whose role may not create tables, or that reviews DDL before it runs. Take these, put them wherever your migrations live, and never call :meth:create_schema.

Every statement is conditional, so applying them to a database that already holds the tables changes nothing.

create_schema

create_schema() -> None

Creates the tables this adapter reads, if they are not there.

Every statement is IF NOT EXISTS, so calling it against a database that already has them changes nothing.

Safe to call from every replica at once. IF NOT EXISTS is not atomic in Postgres: two sessions can both find a table missing and one then fails creating it. A lock around the whole thing serialises them, and it is released when the transaction ends however it ends.

:meth:schema_sql hands over the same statements for an application that would rather run them itself.

close_expired

close_expired(
    store: BoardStore, limit: int = 100
) -> list[str]

Closes the runs past a deadline that no process is watching, and names them.

A run closes because nothing happened, so no request is in flight to notice. Any process holding the store may call this: closing is a write only one caller wins, so several callers running it together still close each run once.

Call it on whatever schedule suits the deployment, being a thread beside the service, a scheduled job, or a test advancing time by hand. The library takes no view. limit bounds one pass.

reader_for

reader_for(store: BoardStore, board_id: str) -> BoardReader

Returns a read handle over one board of a store, with no run behind it.

A read needs the record and not the run, so a process holding the store can serve a read for any board in it. Writing needs a Control.

relay_unsent

relay_unsent(
    store: BoardStore,
    control_for: Callable[[str], Control | None],
    limit: int = 100,
) -> list[str]

Sends what a write recorded and nothing has sent, and names the agents.

The other job that needs a schedule. :func:close_expired takes a store, because closing needs nothing else; sending needs the callables, which no store holds, so this takes a callable of yours answering with the Control for a board. It is the same callable BoardService takes.

A board yours answers nothing for is left, because another replica holds what this one does not. One board failing is no reason to leave the rest of the pass undone.

limit bounds how many owed notifications one pass reads.

attach_model

attach_model(
    *,
    board_id: str,
    store: BoardStore,
    regions: Iterable[Level | Premise],
    agents: Iterable[Agent] | None = None,
    admission_rule: AdmissionRule | None = None,
    termination_predicate: TerminationPredicate
    | None = None,
    limits: RunLimits,
    clock: Clock | None = None,
    on_open: Callable[[Model], None] | None = None,
    on_closed: Callable[[RunOutcome], None] | None = None,
    reach: Callable[[str, Notification], None]
    | None = None,
) -> Model

Opens a run over a board that already holds a record.

create_model declares the regions, so it opens a board once. This opens a run over one that exists, which is what a replica replacing the one that died does, and what a service resuming a run it evicted does.

There is no premises argument. The record already holds their values, and the versions they are at.

regions still names what the run expects, and is checked against what the record holds. A board holding no regions is refused rather than quietly created, because attaching to nothing would build a run whose every write is rejected. A name or a kind that disagrees with the record is refused naming the region.

The run carries over with it: its agents, what wakes each and where it is reached, and how far each has been told and has answered. What does not carry over is the callables, which no store holds. An agent this process is to reach in line is declared here again with its callback; one the run records an address for is reached by the transport given as reach.

So an agent declared against the attached run resumes from what it answered rather than being told the whole board again, and an agent that had answered everything is declared without being woken.

create_model

create_model(
    *,
    board_id: str,
    store: BoardStore,
    regions: Iterable[Level | Premise],
    premises: Mapping[str, object],
    agents: Iterable[Agent] | None = None,
    admission_rule: AdmissionRule | None = None,
    termination_predicate: TerminationPredicate
    | None = None,
    limits: RunLimits,
    clock: Clock | None = None,
    on_open: Callable[[Model], None] | None = None,
    on_closed: Callable[[RunOutcome], None] | None = None,
    reach: Callable[[str, Notification], None]
    | None = None,
) -> Model

Opens a run and returns the model.

limits carries the run's wall clock and idle durations.

board_id names this board inside the store. The caller supplies it, because only the caller knows what identifies a run in its own system. The library never reads it.

store says where the record is kept and has no default, because a run whose record no second process can read is not a shared solution model. One store serves every board an application runs. SqliteStore serves one machine; an adapter against your own database serves a deployment.

agents names the agents the run starts with, and naming one twice is refused, because a roster is one list written at one moment. An agent that registers again later is a returning agent rather than a mistake, and replaces its own declaration. Each is registered once the premises hold their opening values, so each receives one notification covering everything already on the board. An agent that joins a run already under way registers itself through Control.register_agent instead.

Everything the arguments alone can settle is settled before the store is touched: the declarations naming each region once, the opening premises being exactly the declared premises, each opening value being JSON, the roster naming each agent once, and every region an agent subscribes to or writes to being declared. A call that raises here has written nothing, so the corrected call opens the board it was going to open.

premises gives every declared premise its opening value, naming each one exactly once. Those writes bypass admission, because they are the application's own input rather than a proposal from a writer.

No agent is registered yet, so opening the premises wakes nobody; an agent registering afterwards is woken then. With no admission rule a write is accepted subject to the region existing, the limits holding, and the run being open; with no termination predicate the run closes when nothing has happened for the idle limit; with no clock the operating system clock serves.

blackboard.wire

blackboard.wire

What crosses between a blackboard and an agent.

The bodies, and the operations that carry them. Both halves import this module, so one half cannot spell a field differently from the other or send to a path the other does not answer on.

Nothing here depends on a web framework or on an HTTP library. A body is a frozen dataclass with to_json and from_json, so a route hands the result of to_json to its framework and passes what it received to from_json. blackboard.server answers these operations and blackboard.agent calls them; a service that would rather write its own routes still has the bodies.

Decoding is tolerant, because the two halves are deployed separately and are therefore versioned separately. A field that a decoder does not recognise is ignored, a field that is absent takes its default, and a name is never reused for a different meaning. An older agent can therefore read a body from a newer blackboard, and a newer agent can read a body from an older one.

WireError

Bases: Exception

A body could not be decoded because a field it needs is missing.

NotificationBody dataclass

Bases: _Body

One notification, as it reaches an agent.

It carries no values. The agent reads the board to find out what changed, which is why a repeat costs nothing and why order does not matter.

Both bounds are required. A body that lost from_sequence would decode as zero, which is a range the control component never issues and which tells an agent to read the whole level. regions stays optional, because an agent may read past what a notification names.

WriteRequest dataclass

Bases: _Body

A contribution proposed to a level.

content is required, because a body that carries none would otherwise store null. level has a default, because the path already names the region and the service takes it from there; a service that mounts one route of its own can still send it.

SetPremiseRequest dataclass

Bases: _Body

A premise value proposed under the version it expects to replace.

AckRequest dataclass

Bases: _Body

An agent reporting that it has stopped working on one notification.

WrittenBody dataclass

Bases: _Body

A write that reached the board, at the sequence it received.

version is absent on a level write, because a level holds none.

ConflictBody dataclass

Bases: _Body

A premise write that named a version other than the current one.

RejectedBody dataclass

Bases: _Body

A write the control component refused, with the cause.

ContributionBody dataclass

Bases: _Body

One contribution read back from a level.

writer and written_at are optional, so a body from an older blackboard that carries no such field decodes as None for both and a newer agent reads it without error. written_at crosses as an ISO-8601 string.

BoardChangeBody dataclass

Bases: _Body

One write to any region, read back from the whole board.

writer and written_at follow the rule on :class:ContributionBody.

LevelPage dataclass

Bases: _Body

Part of a level. has_more is set when the level holds more beyond it.

A reader continues from one past the last sequence it received. That is the cursor: an offset would shift when a concurrent write lands, and a sequence number does not.

BoardPage dataclass

Bases: _Body

Part of the whole board. has_more is set when the board holds more beyond it.

PremiseBody dataclass

Bases: _Body

A premise's current value and the version that produced it.

writer and written_at follow the rule on :class:ContributionBody.

RegionBody dataclass

Bases: _Body

One declared region: its name and its kind.

A store records a name and a kind and nothing else, so a premise comes back without the batch window it was declared with. The window tells the control component when to notify and is no part of the record.

of classmethod

of(region: Level | Premise) -> RegionBody

Builds a body from a declaration.

declaration

declaration() -> Level | Premise

Builds a declaration from a body.

RegionList dataclass

Bases: _Body

Every region declared on one board.

ErrorBody dataclass

Bases: _Body

What a blackboard answers with when it cannot do what was asked.

error is a stable name a client can branch on. detail is written for a person reading a log and may change between versions.

Operation dataclass

One thing an agent can ask a blackboard to do.

template names its variables in braces. Both halves build a path from the same object, one to send and one to match, so a rename reaches the two of them together.

variables property

variables: tuple[str, ...]

Returns the names this operation's template takes, in order.

path

path(**variables: str) -> str

Returns the path for these variables, each percent-encoded.

blackboard.server

blackboard.server

Answering an agent's request from the blackboard's side.

An agent that runs as its own service reads and writes over HTTP. The operations it needs are the ones :class:~blackboard.Control already has, so the only questions left are which path carries which operation and which status code carries which answer. Both halves of this library take those answers from :mod:blackboard.wire, so a service and its agents cannot disagree about them.

:class:BoardService is the blackboard's side. It takes a method, a path, and a decoded body, and returns a status, headers, and a body. It imports no web framework and opens no socket: the application keeps its own server, its routing prefix, and its authentication, and hands each request through.

from blackboard.server import BoardService, Request

runs: dict[str, Control] = {}
service = BoardService(control_for=runs.get, prefix="/v1")

@app.route("/v1/<path:rest>", methods=["GET", "POST", "PUT"])
def blackboard(rest: str):
    answer = service.handle(
        Request(
            method=flask.request.method,
            path=flask.request.path,
            body=flask.request.get_json(silent=True),
            query=flask.request.args,
        )
    )
    return answer.body or "", answer.status, answer.headers

One :class:~blackboard.Control serves one board, and control_for finds the one a request names. A dictionary's get is the usual answer; a callable that builds a run on first sight is another.

path is matched a segment at a time and each variable is decoded once, so a board identifier that holds a slash arrives whole as long as the framework hands over the path it received. Some frameworks decode the path before you see it, so keep a board identifier opaque: a UUID travels through every one of those frameworks unchanged.

What the status codes mean

====== ========================================================== Status Meaning ====== ========================================================== 200 A read, or a write an idempotency key had already made 201 A write reached the board for the first time 204 An acknowledgment was recorded 400 The body or a query parameter could not be read 404 No such board, region, notification, or path 405 That path takes a different method 409 A premise moved on, or a key named another region 410 The run has closed and takes no more writes 422 The write was refused; the answer names the cause ====== ==========================================================

409 and 422 are answers rather than faults, so a client that sends the same request again gets the same one. Only 5xx and a failure to connect are worth another attempt.

Request dataclass

One request, as the service's own framework already holds it.

body is JSON that has already been parsed, or None where the request carried none. query is the query string parsed into single values; a parameter given twice keeps whichever value the framework kept.

Response dataclass

What to answer with: a status, headers, and JSON or nothing.

BoardService

Answers the operations in :mod:blackboard.wire from a set of runs.

control_for returns the control component for a board identifier, or None when this service holds no run for it. prefix is where the application mounted these paths, and is stripped before matching.

handle

handle(request: Request) -> Response

Answers one request. Raises nothing the caller has to catch.

blackboard.delivery

blackboard.delivery

Sending notifications to agents over HTTP.

The control component reaches an agent by calling Agent.notify. Left to itself that call runs on the thread of whichever agent just wrote, one agent after another, so a write that took microseconds pays for every agent's round trip and one agent whose endpoint hangs stalls the writer for the whole timeout.

:class:HttpNotifier supplies that callable. It puts the notification on a queue and returns, and a worker sends it. Each call to :meth:HttpNotifier.to opens its own lane, so agents are reached at the same time and an agent that is slow, retrying, or down delays only itself.

from blackboard import Agent, create_model
from blackboard.delivery import HttpNotifier

with HttpNotifier(store=store) as notifier:
    model = create_model(
        board_id=board_id,
        store=store,
        regions=[...],
        premises={...},
        agents=[
            Agent(
                name="ocp",
                subscribes_to={"signals"},
                notify=notifier.to("https://ocp.internal/notify"),
            )
        ],
        limits=limits,
    )

The queue is held in memory, so a process that stops loses whatever had not been sent. Give the notifier the store and it is not lost: a lane returns before the send, so the lane clears the outbox row itself once it has sent, and what the queue was still holding is still owed and reaches the agent through :meth:blackboard.Control.relay. Without a store, the control component marks the row when the lane accepts the notification, having no way to see what became of it.

DeliveryFailed

Bases: BlackboardError

A delivery did not land and is worth attempting again.

Raised for a refused connection, a timeout, a 5xx, or a 429. retry_after carries what the server asked for, where the server said so.

DeliveryRefused

Bases: BlackboardError

A delivery the agent will refuse again.

Raised for a 400, a 404, or a 422. Sending the same body a second time produces the same answer, so the notifier reports it rather than retrying. It is not a DeliveryFailed, because that one says another attempt might land.

Undelivered dataclass

What the notifier reports when it stops trying.

attempts is how many times the transport was called, so it is zero for a notification handed to a notifier that had already closed.

Transport

Bases: Protocol

How one notification leaves the process.

:class:HttpxTransport is the implementation the library ships. Supply another to send over something else, or to send nothing in a test.

send

send(url: str, body: dict[str, Any]) -> None

Delivers body to url as JSON.

Returns when the agent accepted it. Raises :class:DeliveryRefused when the agent will refuse it again, and :class:DeliveryFailed, or any other exception, when another attempt might land.

close

close() -> None

Releases whatever the transport holds open.

HttpxTransport

Sends over HTTP with httpx.

Install it with pip install blackboardx[notifier]. One client is held open for the life of the transport, so agents reached repeatedly reuse a connection.

send

send(url: str, body: dict[str, Any]) -> None

Posts body as JSON and maps the answer onto the two failures.

close

close() -> None

Closes the client and the connections it holds.

Lane

One agent's delivery lane: its queue, and the worker that drains it.

What :meth:HttpNotifier.to returns. Call it to deliver a notification, which is why an Agent takes it directly as notify. Close it when the run that used it ends, which stops its worker and releases its queue without closing the notifier or any other lane.

Calling it queues and returns, so the notification is not on the wire when the call comes back. marks_sent says whether this lane records its own sends, which the control component reads to decide whether the outbox row is its to mark. It is true where the notifier was given a store, and a notification only queued is then still owed until the lane has sent it.

marks_sent property

marks_sent: bool

True when this lane marks the outbox row itself, after the send.

close

close() -> None

Stops this lane and reports whatever it still held.

Returns once it has reported, so nothing is left queued behind a closed lane. Closing twice does nothing the second time, and closing after the notifier has closed is safe.

HttpNotifier

Sends notifications to agents without the writer waiting.

transport defaults to :class:HttpxTransport, built when the notifier is built. attempts counts every call to the transport, so 4 means one send and three retries. backoff decides the wait between them. on_failure is called on the lane's own thread, once for every notification the notifier gives up on; a failure is logged at ERROR whatever it does.

Closing drains what is queued, waiting up to close_timeout seconds in total rather than per lane. Whatever is still queued at that point is reported through on_failure before close returns. A transport the notifier built is closed with it; one you supplied is yours to close.

records_sends property

records_sends: bool

True when this notifier was given a store to mark rows in.

reach

reach(address: str, notification: Notification) -> None

Sends one notification to an address, on the caller's thread.

The transport a Control takes as reach, for an agent the run records an address for and this process holds no callable for. It opens no lane and starts no thread, so a notifier used only this way holds nothing between calls.

Unlike a lane, this sends once and does not return before the send. The relay is the retry: it marks a row sent only when this returns, so a raise here leaves the row for the next pass rather than being reported through on_failure.

to

to(url: str) -> Lane

Opens a lane to one agent and returns it.

The lane is the notify callable an Agent is created with, and it can be closed on its own. Each call opens a lane of its own, so give every agent its own even when two agents share an address: two agents behind one lane share a queue and take turns.

Close a lane when the run that used it ends. A notifier serving many runs over its life would otherwise hold a queue and a thread for every agent of every run it has ever served.

close

close() -> None

Sends what is queued, stops the lanes, and returns.

Waits up to close_timeout seconds in total, not per lane. After that, a lane still parked in a retry abandons it, and everything it still holds is reported through on_failure before this returns.

A transport this notifier built is closed here. One that was supplied is left open, because the caller that opened it owns it.

Closing twice does nothing the second time.

default_backoff

default_backoff(
    attempt: int, retry_after: float | None
) -> float

Returns how long to wait before attempt + 1, in seconds.

A server that named a delay gets that delay, capped at :data:MAX_BACKOFF so one bad header cannot park a caller for an hour. Otherwise the wait doubles with each attempt and is drawn from the range between half of that and all of it, so callers that failed together do not return together.

blackboard.agent

blackboard.agent

Calling a blackboard from an agent.

An agent that runs as its own service reads and writes over HTTP. This module is the agent's side of the protocol that :mod:blackboard.wire states, so an agent names no URL, no status code, and no header.

A client is bound to one board and one agent name, the way :class:~blackboard.BoardReader is bound to one board.

:class:BoardClient satisfies :class:~blackboard.AgentBoard, the protocol that one board looks like to one agent, and what :meth:~blackboard.Control.as_agent returns satisfies it too. An agent body written against AgentBoard therefore runs in the blackboard's own process or against one over HTTP, with nothing changed between them. It also satisfies BoardReader on its own, so an admission rule or a termination predicate written against that protocol reads a remote board as well as a local one.

from blackboard.agent import BoardClient
from blackboard.wire import NotificationBody

@app.post("/notify")
def notify(body: dict):
    notification = NotificationBody.from_json(body)
    with BoardClient(
        base_url="https://blackboard.internal/v1",
        board_id=notification.board_id,
        agent="triage",
    ) as board:
        for change in board.read_level("signals", notification.from_sequence):
            ...
        board.write("findings", {"cause": "a bad deploy"})
        board.ack(notification.notification_id)

:class:AsyncBoardClient is the same surface with await in front of every method. One is not written in terms of the other: a synchronous method that starts an event loop per call throws away the connection pool, so both are implemented separately and the request they build is one piece of shared code.

Install the agent extra with pip install blackboardx[agent].

What comes back, and what is raised

A write answers the way :meth:~blackboard.Control.write answers, with :class:~blackboard.Written or :class:~blackboard.Rejected, and setting a premise adds :class:~blackboard.Conflict. Those are answers rather than faults, and sending the same request again gets the same one.

Everything else is raised, and where the blackboard has an exception for it, the client raises that exception: :class:~blackboard.UndeclaredRegionError, :class:~blackboard.RegionKindError, :class:~blackboard.UnsetPremiseError, :class:~blackboard.UnknownNotificationError. A board that this blackboard does not hold raises :class:UnknownBoardError, a blackboard that could not be reached raises :class:Unreachable, and an answer that the two halves disagree about raises :class:ProtocolError.

What is attempted again

A read and an acknowledgment are attempted again when the blackboard cannot be reached or answers 5xx. Reading twice returns the same thing, and an acknowledgment of a notification no longer outstanding changes nothing.

A write is attempted again only when it carries an idempotency_key. A request that timed out may still have been received, and a contribution appended twice does not leave the same board, so a write with nothing to identify it raises :class:Unreachable and the agent decides what to do. Given a key, the blackboard writes it once however many times it arrives, so the client sends it again like a read, and an attempt whose answer was lost comes back as the first one's result with repeated set.

Name the key after work the agent can name again after a restart, rather than a fresh random string each time, so that a write repeated after a restart is deduplicated too.

Retrying a keyed write needs a blackboard at 0.8 or later. An older one takes the key and ignores it.

UnknownBoardError

Bases: BlackboardError

The blackboard answered that it holds no board by that identifier.

Unreachable

Bases: BlackboardError

The blackboard could not be reached, or kept answering 5xx.

ProtocolError

Bases: BlackboardError

The blackboard answered something this client cannot make sense of.

A body that will not decode, a status no operation produces, or a request this client built that the blackboard refused as malformed. It means the two halves are out of step rather than that the call was wrong.

BoardClient

Reads and writes one board, as one agent, over HTTP.

base_url is where the blackboard mounted the operations, including any prefix. http_client takes an httpx.Client you configured yourself, with the authentication, the certificates, or the proxy your deployment needs; without one, BoardClient builds a plain httpx.Client and closes that client with itself.

board_id property

board_id: str

The board this client reads and writes.

read_regions

read_regions() -> list[Level | Premise]

Returns the regions declared on this board, with their kinds.

read_level

read_level(
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

Without a limit this reads the level to its end, following the blackboard's pages. With a limit it returns at most that many.

read_premise

read_premise(premise: str) -> PremiseState

Returns a premise's current value and version.

read_board

read_board(
    from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

write

write(
    level: str,
    content: object,
    idempotency_key: str | None = None,
) -> Written | Rejected

Proposes a contribution to a level, as this client's agent.

set_premise

set_premise(
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
) -> Written | Conflict | Rejected

Sets a premise, provided it is still at expected_version.

ack

ack(notification_id: NotificationId | int) -> None

Records that this agent finished responding to a notification.

close

close() -> None

Closes the HTTP client, unless one was supplied.

AsyncBoardClient

:class:BoardClient with await in front of every method.

It builds the same requests and reads the same answers; the sending differs. http_client takes an httpx.AsyncClient.

board_id property

board_id: str

The board this client reads and writes.

read_regions async

read_regions() -> list[Level | Premise]

Returns the regions declared on this board, with their kinds.

read_level async

read_level(
    level: str,
    from_sequence: int = 0,
    limit: int | None = None,
) -> list[Contribution]

Returns a level's contributions from the sequence bound, inclusive.

read_premise async

read_premise(premise: str) -> PremiseState

Returns a premise's current value and version.

read_board async

read_board(
    from_sequence: int = 0, limit: int | None = None
) -> list[BoardChange]

Returns every write to every region, in sequence order, from the bound.

write async

write(
    level: str,
    content: object,
    idempotency_key: str | None = None,
) -> Written | Rejected

Proposes a contribution to a level, as this client's agent.

set_premise async

set_premise(
    premise: str,
    value: object,
    expected_version: int,
    idempotency_key: str | None = None,
) -> Written | Conflict | Rejected

Sets a premise, provided it is still at expected_version.

ack async

ack(notification_id: NotificationId | int) -> None

Records that this agent finished responding to a notification.

close async

close() -> None

Closes the HTTP client, unless one was supplied.

default_backoff

default_backoff(
    attempt: int, retry_after: float | None
) -> float

Returns how long to wait before attempt + 1, in seconds.

A server that named a delay gets that delay, capped at :data:MAX_BACKOFF so one bad header cannot park a caller for an hour. Otherwise the wait doubles with each attempt and is drawn from the range between half of that and all of it, so callers that failed together do not return together.

blackboard.tools

blackboard.tools

Every method of AgentBoard, as a tool a model can call.

An agent decides what to write. Where that decision is an algorithm, the application calls the methods of AgentBoard itself and needs nothing here. Where the decision is a language model, the application cannot hand those methods to a model API, because such an API takes a schema for each thing it may call and answers with a request to call one by name. This module renders each of those methods into that form and runs what comes back.

The list is the application's to build. Each tool is a name this module exports, ALL is every one of them, and ToolSet holds the ones an application chose. Two agents on one board can therefore be offered different tools, because what a model is allowed to reach for is a decision about that agent rather than about this library.

The rendering is not a copy of the protocol, and the four differences are why it belongs here rather than in each application.

The agent's name is absent from every schema. AgentBoard carries it, so a model cannot write under a name other than the one the caller bound.

idempotency_key is absent from every schema and is filled from the identifier the model API gives the call. A loop that sends a call twice therefore writes once.

An outcome the model can act on comes back as text it can read, because a model reads results and catches no exceptions. A rejected write and a premise whose version moved are values on the protocol already. Five conditions the protocol raises are answered here instead: a region the board does not hold, a name of the other kind, a premise with no value yet, a key that already wrote to another region, and a notification this agent never had. The first two come back naming the regions the board holds.

A read that answers with a list is bounded. A list too large for a model to hold is cut, and the result says how many entries it left out. A premise's value is answered whole, because one value cut in half is a value the model cannot use.

ACK needs care in a way the others do not. Acknowledging says the agent has stopped working on a notification, so a model that sends it partway through its own reasoning tells the run that this agent has finished while it is still working. An application that keeps acknowledgment in the loop around the model leaves ACK out of the set it offers.

UnknownToolError

Bases: BlackboardError

Raised when a call names a tool this toolset does not hold.

A caller offers its own tools beside these, and routes on ToolSet.owns so that its own names reach its own code rather than this one.

ToolAnnotations dataclass

What a tool does to the board, for a caller choosing which to offer.

read_only is false for a tool that writes. idempotent says that running the same call twice leaves the board as running it once did, which holds when the caller passes the model call's identifier, because a write takes that identifier as its idempotency key.

ToolDescriptor dataclass

One method of AgentBoard, as a model API accepts it.

method names the method this calls, and is what ties the schema to the protocol. The parameters the schema offers, taken together with the ones withholds lists, are exactly that method's parameters, and a test holds them to it.

withholds lists the parameters deliberately absent from the schema. from_call_id lists the ones filled from the caller's identifier for the model call, and each of those is withheld.

definition

definition() -> dict[str, Any]

Returns this tool in the shape the Model Context Protocol defines.

for_anthropic

for_anthropic() -> dict[str, Any]

Returns this tool as the Anthropic Messages API takes it.

for_openai

for_openai() -> dict[str, Any]

Returns this tool as the OpenAI chat completions API takes it.

ToolResult dataclass

What one tool call produced.

content is what goes back to the model. is_error marks a call the model got wrong, which it can correct and send again; a write the run refused is not one of those, because the model asked correctly and the run said no. value is what the board returned, for a caller that wants that value rather than the text rendered from it, and is None where the call did not reach the board.

ToolSet

Bases: Sequence[ToolDescriptor]

The descriptors a caller offers to a model, each under its own name.

A caller offers a set to a model and runs what the model asks for against a board. Selecting a subset returns another set, so a caller that offers a model only the reads runs those calls through the object it offered.

owns

owns(name: str) -> bool

Says if this set holds the tool that a call names.

A model answers with the name of a tool, and the caller's own tools sit in the same offer, so a caller asks this before handing a call to run.

descriptor

descriptor(name: str) -> ToolDescriptor

Returns the descriptor under this name.

Raises UnknownToolError where this set holds no such name.

select

select(*names: str) -> ToolSet

Returns the descriptors under these names, in this set's order.

read_only

read_only() -> ToolSet

Returns the tools that read the board and never write to it.

definitions

definitions() -> list[dict[str, Any]]

Returns each tool in the shape the Model Context Protocol defines.

A server answering tools/list returns this as it stands. The two provider shapes carry the same name, description and schema under the key names each of those APIs uses.

for_anthropic

for_anthropic() -> list[dict[str, Any]]

Returns the tools as the Anthropic Messages API takes them.

for_openai

for_openai() -> list[dict[str, Any]]

Returns the tools as the OpenAI chat completions API takes them.

run

run(
    board: AgentBoard,
    name: str,
    arguments: Mapping[str, Any],
    *,
    call_id: str | None = None,
) -> ToolResult

Runs one call the model asked for, against this board.

name and arguments are what the model API answered with, and call_id is that API's identifier for the call, which becomes the write's idempotency key. A name this set does not hold raises UnknownToolError, because the caller's own tools are the caller's to route.

Arguments are checked before the board is touched, so a malformed call changes nothing and comes back saying what was wrong with it.

blackboard.conformance

blackboard.conformance

The suite every store implementation is held to.

BoardStore is a protocol, so an application may keep its record in a database that this library ships no adapter for. What that store has to guarantee is not obvious from the signatures: one counter across every region rather than one per region, a conflicting premise write taking no sequence number so a conflict leaves no gap, a key writing once, a bounded read continuing from a sequence rather than an offset. Each of those has a case here because an implementation gets each of them wrong.

Subclass BoardConformance and SharedStoreConformance, supply a store fixture returning a fresh store, and run pytest:

from blackboard.conformance import BoardConformance, SharedStoreConformance
from myapp.storage import CassandraStore


class TestCassandraStore(BoardConformance):
    @pytest.fixture
    def store(self):
        return CassandraStore(session)


class TestCassandraHoldsManyBoards(SharedStoreConformance):
    @pytest.fixture
    def store(self):
        return CassandraStore(session)

The four stores the library ships are held to this module rather than to a copy of it.

Running it needs pytest, which the library itself does not need: pip install blackboardx[conformance].