Skip to main content

rekuest

codecov PyPI version Maintenance Maintainer PyPI pyversions PyPI status

Self-documenting, asynchronous, scalable RPC for untrusted actors — accessible through FastAPI or deployed on the Arkitekt platform.

Turn an ordinary, typed Python function into a remotely callable action: rekuest inspects its signature and docstring to build a self-documenting schema, hosts it as a provisionable actor, and lets other apps discover and call it — with fine-grained, per-app access control.

Why rekuest?

Most RPC frameworks expose functionality on a server. rekuest flips that around: functionality lives on the client, and any app can provide actions on a user's behalf. That makes it a good fit for scientific and automation workflows where:

  • Code is the contract. Type hints and docstrings become the schema — args, returns, widgets, and documentation are derived automatically, no IDL to maintain.
  • Apps are untrusted. Every app negotiates access to data and to other apps via OAuth2, so you can safely run third-party or user-contributed code.
  • Work is distributed. Many agents can provide the same action; the platform load balances, retries, and fails over calls across them.
  • Calls compose. An action can call other actions, so you can build pipelines that span apps and machines.

How it works

  @register function  ──inspect──▶  Action (self-documenting schema)
        │                                  │
        │ hosted by an Agent               │ discovered + reserved by callers
        ▼                                  ▼
      Actor  ◀───────── assign / call ─────┘
   (running instance,
    expands inputs, runs,
    shrinks outputs)
Term Meaning
Action The documented concept of a function that is available on the platform.
Implementation A concrete realization of an Action provided by an App.
App A provider of functionality that negotiates access rights through OAuth2.
Agent A running instance of an App; the host of actors. Agents connect and disconnect.
Actor A provisioned, running instance of a function that processes assignments.
Provision A contract obliging an Agent to keep an action available (think Deployment in K8s).
Reservation A contract for a user to call one or more actors; the platform load balances and heals it.

Prerequisites

A running rekuest server (most easily obtained through an Arkitekt deployment).

Install

rekuest is usually consumed through the Arkitekt platform, which wires up the server connection, authentication and lifecycle for you. Installing arkitekt-next pulls in rekuest as well:

pip install arkitekt-next

Doing image analysis? The Arkitekt platform ships ready-made data structures for imaging (see mikro).

Providing an action

Register a typed function — its signature and docstring become the schema:

from arkitekt_next import register, easy


@register
def add_greeting(x: int, name: str) -> str:
    """Add a greeting.

    Takes a number and a name and returns a friendly message.

    Args:
        x (int): A number to include in the message.
        name (str): The name to greet.

    Returns:
        str: The greeting message.
    """
    return f"Hello {name}, your number is {x}"


with easy("my_app") as app:
    # Connects, registers the action, and provides it until interrupted.
    app.run()

Run it during development with:

arkitekt-next run dev

The action is now registered under your app and signed-in user, and can be provisioned and called by other apps. By default users may only assign to their own apps; this can be relaxed on the rekuest server.

Calling an action

Resolve an action with find (by its id, or pass an Action straight through), then invoke it with call (sync) or acall (async):

from arkitekt_next import easy
from rekuest import find, call


with easy("my_app") as app:
    action = find(action_id)            # action_id discovered via search / the platform

    result = call(action, x=1, name="world")
    print(result)                       # "Hello world, your number is 1"

When you already know which agent provides an implementation, you can address it directly and await the result:

from rekuest.api.schema import amy_implementation_at
from rekuest.remote import acall

impl = await amy_implementation_at(instance_id, "add_greeting")
result = await acall(impl, x=1, name="world")

Working with complex data structures

rekuest serializes and documents the standard Python types out of the box:

str · bool · int · float · Enum · dict · list

Large or complex objects (e.g. numpy arrays) should not be serialized into messages. rekuest offers two strategies instead.

Global structures — reference-by-id

Store the object in central storage and pass only a reference. Make a class a structure with the @structure decorator — give it an identifier and async ashrink/aexpand methods:

from rekuest import structure


@structure(identifier="myapp/image")
class Image:
    id: str  # a reference to this object in central storage

    async def ashrink(self) -> str:
        return self.id

    @classmethod
    async def aexpand(cls, value: str) -> "Image":
        return await cls.load_from_server(value)

The decorator registers the structure eagerly. A class that instead implements a get_identifier() classmethod alongside ashrink/aexpand is still picked up automatically the first time it is used in a type hint.

Now you can use Image directly in type hints — rekuest automatically ashrinks (serializes) it to its reference when sending and aexpands (deserializes) it back on the receiving side:

@register
def brightest_pixel(image: Image) -> int:
    return image.max()

Memory structures — keep it on the shelve

Sometimes an object only makes sense within the agent that produced it and never needs to leave the machine — a handle to an open file, a live model, an intermediate result. Any plain class with no serialization protocol is treated as a memory structure: instead of being serialized, it is parked on the agent's in-memory shelve, and only an opaque drawer reference travels on the wire.

class Session:  # no get_identifier / ashrink / aexpand -> memory structure
    def __init__(self, handle):
        self.handle = handle


@register
def open_session(path: str) -> Session:
    return Session(open(path))


@register
def read_session(session: Session) -> str:
    return session.handle.read()

Because the object stays on the agent's shelve, piping the reference returned by open_session into read_session resolves the same live object — no copy, no re-serialization. See tests/test_app_memory_structure.py for end-to-end examples.

State

Actions are stateless by default — each call is independent. When an agent needs to remember something across calls (a connection, a counter, a loaded model), declare an observable state with @state on a dataclass:

from dataclasses import dataclass
from rekuest import state, startup


@state
@dataclass
class CounterState:
    count: int = 0


@startup
def initialize() -> CounterState:
    # Startup hooks return the initial state instances for the agent.
    return CounterState(count=0)

Any action can then read and mutate that state simply by type-hinting a parameter with the state class — rekuest injects the live instance:

@register
def increment(counter: CounterState) -> int:
    counter.count += 1   # mutation is published to the platform automatically
    return counter.count

State is shared by all of an agent's actors and is observable: changes are published live (at publish_interval), so dashboards and other apps can watch it in real time. Use @state(local_only=True) to keep a state on the agent without exposing it to the platform.

Shutdown

Whatever a startup hook opens, a @shutdown hook closes. Shutdown hooks run when the agent tears down — on a clean exit as well as after an error or a cancellation — in the reverse of the order they were registered. Like actions, they take the live state and context objects by type-hinting a parameter, and they return nothing:

from rekuest import shutdown


@shutdown
async def finalize(counter: CounterState) -> None:
    await save_to_disk(counter.count)

A shutdown hook that raises is logged and the remaining hooks still run: teardown never fails because of a hook. Each hook is bounded by the agent's shutdown_hook_timeout (20s by default), so a hook that never returns cannot hang the shutdown.

State a shutdown hook mutates is published like any other state change: the agent's connection outlives its message loop, so the resulting patch is flushed to the platform before the socket closes. A last write on the way out is durable.

Generally, you can also do this with an asyncio.CancelledError handler in your asynchrouns background tasks.

Dependencies

An action can call out to functionality provided by another agent. You describe what you need with a dependency protocol — @declare (a.k.a. agent_protocol) inspects the class so that its public methods become action demands and its @declare_state attributes become state demands:

from rekuest import declare, declare_state


@declare_state
class CameraState:
    connected: bool


@declare(app="lab")
class Camera:
    state: CameraState

    async def snap(self, exposure_ms: float) -> bytes:
        ...   # body is only a signature — it is fulfilled by a remote agent

Consume the dependency by type-hinting a parameter with the protocol class. rekuest resolves a matching remote agent and injects a proxy you can call — .call(...) (sync) or .acall(...) (async). Calls are routed through your agent and tracked as children of the current assignment:

@register
def capture(camera: Camera) -> bytes:
    # Calls the remote agent's `snap` action and returns its result.
    return camera.snap(exposure_ms=10.0)

The platform load balances across matching agents and will heal the reservation if one disconnects. Use auto_resolvable=True, min=, and max= on @declare to control how many matching agents may be bound automatically.

Port validators and effects

A port can carry validators (is the value acceptable?) and effects (hide the port, show a message) that the UI evaluates while the form is being filled in. Both are pure calls into the UI's operation catalog, written as a Python expression. value is the port's own value; any other name is a sibling port, which is subscribed to automatically:

from typing import Annotated
from rekuest import register, withEffect, withValidator
from rekuest.api.schema import EffectKind

@register
def crop(
    start: Annotated[int, withValidator("value >= 0", error_message="Must not be negative")],
    stop: Annotated[int, withValidator("value > start", error_message="Stop must exceed start")],
    note: Annotated[str, withEffect(EffectKind.HIDE, "stop > 1000 and start > 0")] = "",
) -> int:
    return stop - start

Comparisons, arithmetic (x < value + 3, value % 2 == 0; // and ** are not supported), and/or/not, in, is None, x if c else y, len(...), lists, dicts and nested calls are supported; other.child or other["child"] addresses a field inside the value of port other. Operators desugar onto the base catalog (gt, lte, and, if, len_between, ...), a versioned vocabulary every UI implements and the server checks every definition against; annotated_types markers (Gt, Le, Len) become such validators too. UIs may register extension catalogs with more operations; name them with @register(catalogs=["..."]) (several may be combined; base@1 is always applied) to use them (clamp(value=value, min=0, max=10) == value, keyword arguments because the client cannot name positionals of extension operations). Operations neither catalog provides do not block registration: the server stores a warning on the implementation (diagnostics). Inside a call value always means the port's own value, so a sibling port named value cannot be referenced from another port's call.

Bloks — dashboards from JSX

A blok is a declarative UI panel an app contributes to the platform — a small dashboard built from a component tree, wired straight to your actions and state. You write it as a JSX/XML string; jsx(...) parses it into a component tree (with helpful line/column errors when it can't):

from rekuest import jsx

panel = jsx(
    """
    <Page>
      <Label text="Camera" />
      <Text value="$state.camera.connected" />
      <Button title="Snap" onClick="@camera.snap(exposure_ms=10)" />
    </Page>
    """
)

app.register_blok(name="camera_panel", component=panel, description="Camera dashboard")

Props follow three conventions, which is what ties a blok to the rest of your app:

Prop value Meaning
text="Camera" A static value.
value="$state.camera.connected" A dynamic binding to a dependency's state ($state.<dependency>.<path>).
onClick="@camera.snap(exposure_ms=10)" An action callback that calls a dependency's action (@<dependency>.<operation>(...)). Use @utils.<op>(...) for built-in utilities.

Bloks derive their action and state dependencies automatically from the paths they reference, so the platform knows exactly what each panel needs to run.

Development

uv sync                       # install dependencies
pytest -m "not integration"   # fast unit tests
pytest -m integration         # full integration tests (require Docker)

Integration tests spin up the rekuest stack with Docker Compose and exercise real calls against it; see tests/conftest.py.

Learn more

Metadata

Release files for rekuest 3.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for rekuest 3.0.0
File Size Uploaded
rekuest-3.0.0.tar.gz 306.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for rekuest 3.0.0
File Interpreter ABI Platform
rekuest-3.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 679.1 kB

Release files / rekuest-3.0.0.tar.gz

Download URL rekuest-3.0.0.tar.gz
Size 306.0 kB
Tags Source
SHA-256 checksum
How to use checksums
87959541bb6abb3438594ee22a8e8553afcb600586779d96845df5d60d92fc8d
BLAKE2b-256 checksum
How to use checksums
a988b732e84fe8592da00c8e7522c428d0378d731bfdd7f62b9c5871a4eede23
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / rekuest-3.0.0-py3-none-any.whl

Download URL rekuest-3.0.0-py3-none-any.whl
Size 373.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a7effe7c994e50d0e2c4e44c5d419401a3a75f40d4b859f0be37fdf0831a6836
BLAKE2b-256 checksum
How to use checksums
93a99b804104285d3aecc21f6c47abd1a1cd4c31a8f6b154f79d1cf413c0eb08
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

8.3.0

2 release files

8.2.0

2 release files

8.1.0

2 release files

8.0.1

2 release files

8.0.0

2 release files

7.1.0

2 release files

7.0.0

2 release files

6.1.0

2 release files

6.0.1

2 release files

6.0.0

2 release files

5.1.1

2 release files

5.1.0

2 release files

5.0.0

2 release files

4.0.0

2 release files

3.0.2

2 release files

3.0.1

2 release files

This release

3.0.0 This release

2 release files

0.2.27

2 release files

0.2.26

2 release files

0.2.25

2 release files

0.2.23

2 release files

0.2.22

2 release files

0.2.21

2 release files

0.2.20

2 release files

0.2.19

2 release files

0.2.18

2 release files

0.2.17

2 release files

0.2.16

2 release files

0.2.15

2 release files

0.2.14

2 release files

0.2.13

2 release files

0.2.12

2 release files

0.2.11

2 release files

0.2.10

2 release files

0.2.9

2 release files

0.2.8

2 release files

0.2.7

2 release files

0.2.5

2 release files

0.2.3

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.53

2 release files

0.1.52

2 release files

0.1.49

2 release files

0.1.45

2 release files

0.1.44

2 release files

0.1.43

2 release files

0.1.42

2 release files

0.1.41

2 release files

0.1.40

2 release files

0.1.39

2 release files

0.1.38

2 release files

0.1.37

2 release files

0.1.36

2 release files

0.1.35

2 release files

0.1.34

2 release files

0.1.30

2 release files

0.1.29

2 release files

0.1.22

2 release files

0.1.19

2 release files

0.1.18

2 release files

0.1.17

2 release files

0.1.16

2 release files

0.1.15

2 release files

0.1.14

2 release files

0.1.13

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.0.22

2 release files

0.0.21

2 release files

0.0.20

2 release files

0.0.19

2 release files

0.0.18

2 release files

0.0.17

2 release files

0.0.14

2 release files

0.0.13

2 release files

0.0.12

2 release files

0.0.11

2 release files

0.0.8

2 release files

0.0.7

2 release files

0.0.6

2 release files

0.0.5

2 release files

0.0.4

2 release files

0.0.3

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page