Connect your Python backend to a modern JavaScript SPA frontend with 90% less complexity.
Project description
StateZero
Unified, permissioned data, mutations, and actions, wherever they're needed, in realtime.
StateZero is a non-invasive adaptor that turns your existing Django backend into a reactive, real-time data layer. Like Convex or Firebase, but on top of your own models, your own database, and your own permission logic.
No rewrites. No vendor lock-in. You keep your existing Django app and StateZero layers on top. A more philosophical manifesto for the importance of unified state in a world of AI agents is here.
StateZero is also a promise. Every state affordance needed to build AI-native software, implemented thoughtfully, with one guiding question: what would Django do? File uploads, real-time sync, permission introspection, bulk signals, optimistic mutations, query optimization. If you need it, it's already here, and it works the way you'd expect it to.
It's not a separate service. It runs inside your Django process. Mutations go through the ORM, so your existing Django signals (post_save, pre_delete, etc.) fire exactly as they would from a regular view.
Built on Django REST Framework. Authentication, serialization, and request handling use the same battle-tested DRF infrastructure you already know. TokenAuth, SessionAuth, JWT, whatever you use for DRF works out of the box. StateZero doesn't introduce a new HTTP server or require an ASGI migration. It serves over DRF's standard request/response cycle, so it sits happily alongside your existing DRF views. Adopt it for one model, keep your hand-written views for everything else, and expand at your own pace.
Every model gets typed client classes, generated into both TypeScript and Python packages. The ORM interface is standardised across every data access layer: frontend, backend, agent. One way to query. One way to mutate. One set of types. New developers read one set of docs and are productive across the entire stack. AI agents get a typed, self-describing API they can reason about without custom tooling.
Define your permissions and actions once in Django. They become immediately accessible to your Vue/React SPA and your Python agents, with the same query semantics, the same permission enforcement, and no additional views or serializers to write.
Whether it's a query:
Todo.objects.filter({ is_completed: false, priority: "high" }).orderBy("-created_at")
A mutation:
const todo = await Todo.objects.create({ title: "Buy groceries", priority: "high" });
todo.is_completed = true;
await todo.save();
Or an action:
await assignTodo({ todo_id: todo.id, user_id: currentUser.id });
It looks just like the Django ORM, but it's safe to include in untrusted client code or agent sandboxes. Permissions enforced on every call. Any new client works without additional backend code.
Built for Humans and LLMs
StateZero is for human developers who want to deliver world-class AI-native products quickly. It's also for LLMs that want to ship best-of-breed software that leverages the power of the Python ecosystem and Django's battle-tested rails, without having to navigate or write reams of glue code — the kind of boilerplate that's a magnet for an LLM's tendency to over-engineer and add complexity.
With StateZero, LLMs focus on delivering the features their users actually need, built on thoughtful, tastefully implemented abstractions their humans will find easy to grok and reason about.
Consider permissions. In a traditional API, a new feature means a new endpoint, a new serializer, new permission checks. An LLM faithfully builds the 20th this week. Its human is tired, zoned out from reviewing boilerplate, and misses that the LLM misread a permission boundary buried in legacy view code and conflicting documentation. Now sensitive data is leaking to the wrong users. Without StateZero, their human is extremely upset. Maybe they even just got fired.
With StateZero, the LLM just needs to get the permission class right once. As long as its human is happy with that one file, the LLM can build features and display data wherever it needs to, knowing it cannot accidentally expose something it shouldn't. One class, one review, enforced everywhere.
No more catastrophic data leaks from well intentioned, hard working LLMs.
Because everything respects Django ORM conventions, and the generated clients ship with full types and detailed schemas, LLMs can write code confidently. They intuitively understand how it works. It's not like learning a new library. It's Django, on the client. Even down to response shapes and error messages. getOrCreate returns [instance, created] because that's what Django returns. An LLM doesn't have to check the docs. It already knows.
How It Works
Clients query using ORM syntax (.filter(), .exclude(), .order_by()).
No SQL crosses the wire. These compile to a secure, statically analysable AST that the server validates against your permission classes before any database query is executed. Every field path, filter condition, and ordering clause is checked.
The server parses the AST into Django ORM operations, optimizes the query, and returns only what the client asked for and is allowed to see.
A regular user and a superuser run the same query and get different rows, different fields, different permissions. Same endpoint, same permission classes.
Query Semantics
The full Django ORM query language is available from every client. Not a subset. The actual lookup operators, relationship traversals, and field types that Django supports.
Lookup operators. Every standard Django modifier works across the wire:
| Category | Lookups |
|---|---|
| Comparison | exact, iexact, lt, gt, lte, gte, in, range, isnull |
| String | contains, icontains, startswith, istartswith, endswith, iendswith |
| Date/time parts | year, month, day, hour, minute, second, week, week_day, iso_week_day, quarter, iso_year, date, time |
These compose with each other the same way they do in Django: created_at__year__gte, due_date__month__in, updated_at__date__lt.
Relationship traversal. Filter, order, and select across any relationship depth. ForeignKey, OneToOne, ManyToMany, and reverse relations all work:
// FK traversal
Todo.objects.filter({ category__name__icontains: "work" })
// Reverse relation
Category.objects.filter({ todo_set__priority: "high" })
// Deep nesting
Todo.objects.filter({ project__team__organization__name: "Acme" })
// M2M
Todo.objects.filter({ tags__name__in: ["urgent", "blocked"] })
Date and timezone handling. Datetime fields support part extraction and comparison. Django's USE_TZ and TIME_ZONE settings are respected, so timezone-aware queries work correctly:
Todo.objects.filter({ created_at__year: 2025, created_at__quarter: 1 })
Todo.objects.filter({ due_date__week_day: 2 }) // Monday
Todo.objects.filter({ updated_at__date__gte: "2025-06-01" })
JSON field filtering. Filter into arbitrary nested paths on JSONField:
Todo.objects.filter({ metadata__assignee__name__icontains: "alice" })
Todo.objects.filter({ settings__notifications__enabled: true })
Todo.objects.filter({ config__retry_count__gte: 3 })
Q objects. Full boolean logic with AND, OR, and NOT. Nest arbitrarily:
import { Q } from "@statezero/core";
Todo.objects.filter({
Q: [Q("OR", { priority: "high" }, { due_date__lt: "2025-12-31" })],
});
// Python client
Todo.objects.filter(
(Q(priority="high") | Q(due_date__lt="2025-12-31")) & ~Q(status="archived")
).fetch()
F expressions. Reference field values in updates. Arithmetic, abs, round, floor, ceil, min, max:
await Todo.objects.filter({ id: 1 }).update({ view_count: F("view_count + 1") });
await Product.objects.update({ price: F("price * 1.1") });
Todo.objects.filter(id=1).update(view_count=F("view_count") + 1)
Todo.objects.filter(id=1).update(score=F.max(F("score"), F("high_score")))
Aggregations. count, sum, avg, min, max. Run on filtered querysets:
Todo.objects.filter(is_completed=True).count()
Todo.objects.filter(status="active").avg("score")
Todo.objects.filter(status="active").sum("amount")
Todo.objects.min("price")
Todo.objects.max("price")
Ordering. Multi-field, ascending/descending, across relationships:
Todo.objects.filter({ is_completed: false }).orderBy("-priority", "due_date", "category__name")
Every field path in every operation (filters, ordering, F expressions, aggregations) is validated against the user's permissions before the query executes.
Query Optimization
Clients specify depth and/or fields. The server automatically picks select_related for FK/O2O, prefetch_related with scoped inner querysets for M2M/reverse relations, and .only() to limit columns.
The data requirement is defined by the client at the point it's used, not in a separate serializer or view. The server knows exactly what's needed.
Queries are provably optimal with respect to what the client will display. N+1 queries are eliminated. Only the columns and relationships the UI actually renders are fetched. No optimization code required.
const todos = Todo.objects
.filter({ is_completed: false })
.orderBy("-priority")
.fetch({ depth: 1, fields: ["title", "priority", "category__name"] });
Produces select_related("category") and .only("id", "title", "priority", "category_id"). One query, minimal data transfer.
Query Caching & Request Coalescing
Query results are cached per user permission boundary. Two users who see different rows or different fields always get separate cache entries, automatically. No invalidation logic required. New transaction, new cache namespace. No stale data.
When a real-time event fires and multiple clients request the same data simultaneously, only the first request hits the database. The rest wait for that result. Thundering herd solved.
Denormalized Data Store
Too many projects reach for nested serializers to handle cross-model data, embedding full related objects inside each response. It's the path of least resistance, but it means duplicated data on the wire, no shared cache, and no way to keep related objects in sync across different queries.
StateZero uses a denormalized store instead. Responses come back as a flat included map keyed by model type and primary key, with top-level results referencing related objects by PK.
The client resolves relationships automatically from this shared cache. todo.category.name traverses the local store, not a nested blob. When a related object updates, every query referencing it sees the change immediately without re-fetching.
{
"data": [1, 2, 3],
"included": {
"app.Todo": { "1": { "title": "Buy groceries", "category": 5 }, ... },
"app.Category": { "5": { "name": "Personal" }, ... }
}
}
This is the same architecture as Ember Data, JSON:API, and Apollo's normalized cache, but built to work with Django's model graph and permission system.
Building it yourself means implementing PK-indexed storage, relationship resolution, cache invalidation on mutations, and merging partial updates from real-time events. StateZero handles all of it.
Real-Time Sync
Two speed tiers, both automatic.
Local mutations reflect in under 60ms. The JS client maintains a frontend replica of your querysets using query emulation built on sift.js. It handles the full Django query syntax: JSON field filtering, Q objects, M2M fields, lookups like __icontains and __gte. When you create, update, or delete, the local store updates immediately and every active queryset re-evaluates against the new state.
Remote changes from other users, agents, or backend processes arrive in sub-300ms via Pusher. The client merges them into the local store and re-evaluates all active querysets. An agent creates a record and the user's UI updates in the same beat.
Under the hood, StateZero coalesces events within transaction boundaries. A save that touches three models emits one batched update, not three.
Responses include related model data that gets cached in the client's local store, so traversing relationships after a fetch doesn't trigger additional requests.
This is a significant amount of infrastructure. Building it yourself means implementing optimistic state management, a query emulation engine, event coalescing, a client-side cache with relationship resolution, and rollback semantics. StateZero ships it all.
<script setup>
import { useQueryset } from "@statezero/core/vue";
// Re-renders automatically, local mutations in <60ms, remote changes in <300ms
const todos = useQueryset(() => Todo.objects.filter({ is_completed: false }));
</script>
<template>
<div v-for="todo in todos.fetch({ limit: 10 })" :key="todo.id">
{{ todo.title }}
</div>
</template>
Optimistic Mutations
// Optimistic, local store updates in <60ms, server syncs in background
const todo = Todo.objects.create({ title: "Buy groceries" });
todo.title = "Buy organic groceries";
todo.save(); // UI already reflects this. Rolls back if server rejects.
// Confirmed, await server response when you need guarantees
const todo = await Todo.objects.create({ title: "Important meeting" });
await todo.save();
Quick Start
1. Install
pip install statezero
2. Django Settings
# settings.py
from corsheaders.defaults import default_headers
INSTALLED_APPS = [
...,
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"statezero.adaptors.django",
"your_app",
]
MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
...,
"statezero.adaptors.django.middleware.OperationIDMiddleware",
]
CORS_ALLOWED_ORIGINS = ["http://localhost:5173"]
CORS_ALLOW_HEADERS = list(default_headers) + [
"x-operation-id",
"x-canonical-id",
"x-statezero-sync-token",
"x-statezero-extra-fields",
]
STATEZERO_SYNC_TOKEN = "your-secret-token"
STATEZERO_PUSHER = {
"APP_ID": "...",
"KEY": "...",
"SECRET": "...",
"CLUSTER": "eu",
}
3. Register Models
# todos/crud.py
from statezero.adaptors.django.config import registry
from statezero.core.config import ModelConfig
from statezero.adaptors.django.permissions import IsAuthenticatedPermission
from .models import Todo, Category
registry.register(
Todo,
ModelConfig(model=Todo, permissions=[IsAuthenticatedPermission]),
)
registry.register(
Category,
ModelConfig(model=Category, permissions=[IsAuthenticatedPermission]),
)
4. Add URLs
# urls.py
urlpatterns = [
path("statezero/", include("statezero.adaptors.django.urls")),
]
5. Frontend Setup
npm install @statezero/core
Create statezero.config.js in your project root:
const BASE_URL = "http://127.0.0.1:8000/statezero";
export default {
backendConfigs: {
default: {
API_URL: BASE_URL,
SYNC_TOKEN: "your-secret-token",
GENERATED_TYPES_DIR: "./src/models",
GENERATED_ACTIONS_DIR: "./src/actions",
BACKEND_TZ: "UTC",
fileUploadMode: "server",
getAuthHeaders: () => ({
Authorization: `Token ${localStorage.getItem("auth_token")}`,
}),
events: {
type: "pusher",
pusher: {
clientOptions: {
appKey: "your-pusher-key",
cluster: "eu",
forceTLS: true,
authEndpoint: `${BASE_URL}/events/auth/`,
},
},
},
},
},
};
6. Sync Models & Actions
With the Django server running:
npx statezero sync
This fetches schemas from your backend (authenticated via SYNC_TOKEN) and generates typed model classes and action functions into src/models/ and src/actions/.
7. Query From Any Client
JavaScript:
const todos = Todo.objects.filter({ priority: "high" }).orderBy("-created_at");
Python:
todos = Todo.objects.filter(priority="high").order_by("-created_at").fetch()
Same endpoint. Same permissions. Same data shape. Add a new client tomorrow and it works identically.
Permissions
Model permissions and action permissions are separate systems. Model permissions govern data access (queries, mutations). Action permissions govern server-side functions.
Model Permissions
Defined once per model. They govern every query and mutation across all clients. No per-view auth logic, no client-specific rules.
Six independent controls:
| Method | Controls | Result |
|---|---|---|
filter_queryset |
Which rows are visible | Users see only their own data |
exclude_from_queryset |
Which rows are hidden | Archived records hidden from everyone |
allowed_actions |
Which CRUD operations are permitted | Regular users can't delete |
allowed_object_actions |
Per-instance checks | Only owners can edit |
visible_fields |
Which fields appear in responses | Salary hidden from non-managers |
editable_fields / create_fields |
Which fields accept writes | Users set title, not status |
from statezero.core.interfaces import AbstractPermission
from statezero.core.types import ActionType
class ProjectPermission(AbstractPermission):
def filter_queryset(self, request, queryset):
if request.user.is_superuser:
return queryset
return queryset.filter(team__members=request.user)
def exclude_from_queryset(self, request, queryset):
return queryset.exclude(status="deleted")
def allowed_actions(self, request, model):
if request.user.is_superuser:
return {ActionType.CREATE, ActionType.READ, ActionType.UPDATE, ActionType.DELETE}
return {ActionType.CREATE, ActionType.READ, ActionType.UPDATE}
def allowed_object_actions(self, request, obj, model):
if obj.owner == request.user:
return {ActionType.READ, ActionType.UPDATE, ActionType.DELETE}
return {ActionType.READ}
def visible_fields(self, request, model):
if request.user.is_superuser:
return "__all__"
return {"id", "name", "description", "status", "created_at"}
def editable_fields(self, request, model):
return {"name", "description", "status"}
def create_fields(self, request, model):
return {"name", "description"}
A frontend developer and an AI agent with the same auth token get identical boundaries. One code path to audit.
When multiple model permissions are registered, filter_queryset combines with OR (additive, a row is visible if it passes any permission's filter). exclude_from_queryset combines with AND (restrictive, a row is excluded if any permission excludes it).
Built-in model permissions:
AllowAllPermission, full CRUD, all fieldsIsAuthenticatedPermission, authenticated users onlyIsStaffPermission, staff users only
Action Permissions
Actions use a separate permission interface (AbstractActionPermission) with two hooks, one before validation and one after:
from statezero.core.interfaces import AbstractActionPermission
class CanManageProjects(AbstractActionPermission):
def has_permission(self, request, action_name):
"""Before validation. Check the user can call this action at all."""
return request.user.is_authenticated
def has_action_permission(self, request, action_name, validated_data):
"""After validation. Check against the actual input data."""
project = Project.objects.get(id=validated_data["project_id"])
return project.team.members.filter(id=request.user.id).exists()
Compose action permissions with AnyOf (OR) and AllOf (AND):
from statezero.core.permissions import AnyOf, AllOf
@action(permissions=[AnyOf(IsAdminPermission(), CanManageProjects())])
def archive_project(request, project_id: int):
...
Python Client
Generate a standalone Python client from your models. Give an AI agent the client and a scoped auth token. It gets safe, bounded access to data, mutations, and actions. Nothing more.
python manage.py generate_client --output ./sz
Self-contained package. Typed model classes, action functions, httpx-based runtime.
from sz import configure, Q, F, FileObject
from sz.models import Todo
configure(url="https://api.example.com", token="agent-auth-token")
# CRUD
todos = Todo.objects.filter(is_completed=False).order_by("-priority").fetch(limit=20)
todo = Todo.objects.get(id=1)
todo = Todo.objects.create(title="Buy groceries", priority="medium")
todo.update(title="Buy organic groceries")
todo.delete()
# Bulk
Todo.objects.bulk_create([
{"title": "Task 1", "priority": "high"},
{"title": "Task 2", "priority": "low"},
])
# Q objects
urgent = Todo.objects.filter(
Q(priority="high") | Q(due_date__lt="2025-01-01")
).fetch()
# F expressions
Todo.objects.filter(id=1).update(view_count=F("view_count") + 1)
# Aggregations
total = Todo.objects.count()
avg_score = Todo.objects.filter(is_completed=True).avg("score")
# get_or_create
todo, created = Todo.objects.get_or_create(
title="Daily standup", defaults={"priority": "medium"},
)
# Relationships
todo = Todo.objects.get(id=1, depth=1)
print(todo.category.name)
# Field permissions introspection
perms = Todo.get_field_permissions()
# Validation without saving
Todo.validate_data({"title": "", "priority": "invalid"})
# File uploads
Todo.objects.create(title="Report", attachment=FileObject("/path/to/report.pdf"))
# Actions
from sz.actions import send_email
result = send_email(to="user@example.com", subject="Hello", body="World")
JavaScript Client
npm i @statezero/core
npx statezero sync # generates TypeScript models
// Django-style lookups
const todos = Todo.objects.filter({
title__icontains: "meeting",
created_by__email__endswith: "@company.com",
due_date__lt: "2025-12-31",
});
// Q objects
import { Q } from "@statezero/core";
const results = Todo.objects.filter({
Q: [Q("OR", { priority: "high" }, { due_date__lt: "tomorrow" })],
});
// F expressions
import { F } from "@statezero/core";
await Product.objects.update({ view_count: F("view_count + 1") });
// Aggregations
const count = await Todo.objects.count();
// get_or_create
const [todo, created] = await Todo.objects.getOrCreate(
{ title: "Daily standup" },
{ defaults: { priority: "medium" } }
);
Actions
Server-side functions callable from any client. Permissions and validation declared once. Schemas auto-generated into TypeScript and Python clients. Agents and frontends call the same logic with the same checks.
Type-hint your function and StateZero infers the input serializer automatically. str, int, bool, datetime, Optional, List[Model], enums, and Literal all work. Docstring parameter descriptions become help_text on the generated schema.
from typing import List, Optional
from statezero.core.actions import action
from myapp.models import Project, User, Tag
@action(permissions=[IsAdminPermission()])
def transfer_ownership(request, project: Project, new_owner: User, tags: List[Tag], notify: bool = True):
"""Transfer project ownership to another user.
Args:
project: The project to transfer.
new_owner: The user who will become the new owner.
tags: Tags to apply to the transferred project.
notify: Whether to send a notification email.
"""
project.owner = new_owner
project.tags.set(tags)
project.save()
if notify:
send_notification(new_owner, project)
return {"status": "transferred"}
For more control, provide an explicit DRF serializer:
from rest_framework import serializers
class TransferInput(serializers.Serializer):
project_id = serializers.IntegerField()
new_owner_id = serializers.IntegerField()
notify = serializers.BooleanField(default=True)
@action(serializer=TransferInput, permissions=[IsAdminPermission()])
def transfer_ownership(request, project_id, new_owner_id, notify):
...
Display Metadata & Auto-Rendering
Actions and models can declare rich display metadata: field grouping, custom component hints, help text, and layout trees. The schema is served to the frontend, where LayoutRenderer auto-renders the form.
from statezero.core.classes import DisplayMetadata, FieldGroup, FieldDisplayConfig
@action(
permissions=[CanSendNotifications],
display=DisplayMetadata(
display_title="Send Notification",
display_description="Send notifications to multiple recipients",
field_groups=[
FieldGroup(
display_title="Message Content",
field_names=["message", "priority"]
),
FieldGroup(
display_title="Recipients",
field_names=["recipients"]
),
],
field_display_configs=[
FieldDisplayConfig(field_name="message", display_component="TextArea"),
FieldDisplayConfig(field_name="priority", display_component="RadioGroup"),
FieldDisplayConfig(
field_name="recipients",
display_component="EmailListInput",
display_help_text="Add one or more email addresses",
),
],
)
)
def send_notification(message: str, recipients: List[str], priority: str = "low", *, request=None):
...
The same metadata works on model registration:
registry.register(
Product,
ModelConfig(
model=Product,
permissions=[IsAuthenticatedPermission],
display=DisplayMetadata(
display_title="Product Management",
field_groups=[
FieldGroup(display_title="Basic Info", field_names=["name", "description", "category"]),
FieldGroup(display_title="Pricing", field_names=["price", "in_stock"]),
],
field_display_configs=[
FieldDisplayConfig(field_name="description", display_component="RichTextEditor"),
FieldDisplayConfig(field_name="category", display_component="CategorySelector", filter_queryset={"name__icontains": ""}),
FieldDisplayConfig(field_name="price", display_component="CurrencyInput"),
],
),
),
)
The frontend receives this as part of the schema response. LayoutRenderer is a headless Vue component that walks the layout tree and delegates to your component implementations. You provide a Control, Display, Group, Alert, Label, Divider, and Tabs component, and it handles nesting, conditionals, error placement, and form data binding:
<LayoutRenderer
:layout="schema.display.layout"
:schema="schema"
:components="{ Control: MyAutoField, Group: MySection, ... }"
:form-data="formData"
:errors="errors"
@update:formData="formData = $event"
/>
The Control component receives the field schema (type, format, choices, related model) and the display_component hint. It picks the right input widget: a datepicker for dates, a model selector for FKs, a rich text editor when you specify "RichTextEditor".
No manual form building. Add a field to your model or action and it appears in the UI.
For more complex layouts, use the full layout tree with VerticalLayout, HorizontalLayout, Group, Tabs, Conditional, Alert, Label, and Divider elements:
from statezero.core.classes import (
DisplayMetadata, VerticalLayout, HorizontalLayout, Group, Control,
Conditional, Alert, Tabs, Tab, Label, Divider,
)
@action(
display=DisplayMetadata(
layout=VerticalLayout(elements=[
Alert(severity="info", text="This action cannot be undone."),
HorizontalLayout(elements=[
Control(field_name="project", display_component="ProjectSelector"),
Control(field_name="new_owner"),
]),
Conditional(
when="formData.notify === true",
layout=Group(label="Notification Settings", elements=[
Control(field_name="notify_message", display_component="TextArea"),
]),
),
])
)
)
def transfer_ownership(request, project: Project, new_owner: User, notify: bool = True, notify_message: str = ""):
...
Model Registration
from statezero.core.config import ModelConfig
from statezero.core.classes import AdditionalField
registry.register(
MyModel,
ModelConfig(
model=MyModel,
permissions=[MyPermission],
fields={"id", "name", "status"}, # Expose specific fields (default: "__all__")
additional_fields=[ # Computed/virtual fields
AdditionalField(name="full_name", field=models.CharField()),
],
filterable_fields={"name", "status"}, # Restrict filterable fields
searchable_fields={"name", "description"}, # Fields for search
ordering_fields={"name", "created_at"}, # Fields for order_by
pre_hooks=[set_tenant], # Before deserialization
post_hooks=[audit_log], # After deserialization
force_prefetch=["category"], # Always prefetch
),
)
Search
Searches across searchable_fields:
results = Todo.objects.search("meeting").fetch()
For production, use PostgreSQL full-text search with ranked results:
from statezero.adaptors.django.search_providers.postgres_search import PostgresSearchProvider
config.search_provider = PostgresSearchProvider()
Built-In Plumbing
Building agentic software means building a lot of plumbing: file uploads, user context, validation, permission introspection. StateZero ships these out of the box, following Django conventions.
Current User
Every agent and frontend needs to know who it's acting as. GET /statezero/me/ returns the authenticated user as a serialized model, with the same field-level permissions applied.
# Python agent
from sz import configure
configure(url="https://api.example.com", token="agent-token")
# GET /statezero/me/, returns the user this token belongs to
File Uploads
Files are a first-class citizen. The Python client handles upload transparently. Pass a FileObject and it uploads before creating the record. Two modes:
Direct upload. File goes to your server, saved using your existing django-storages configuration:
from sz import FileObject
Todo.objects.create(title="Report", attachment=FileObject("/path/to/file.pdf"))
S3 multipart. Client gets presigned URLs, uploads directly to S3, no file touches your server. Handles chunking for large files automatically:
from sz import configure, FileObject
configure(url="https://api.example.com", token="...", upload_mode="s3")
# 100MB file uploads in chunks directly to S3
Todo.objects.create(title="Dataset", attachment=FileObject("/path/to/large_file.csv"))
Works from bytes and file-like objects too:
Todo.objects.create(title="Generated", attachment=FileObject(b"content", name="output.txt"))
Validation
Validate data without saving. Useful for form validation and agent pre-checks:
# Returns errors without creating anything
Todo.validate_data({"title": "", "priority": "invalid"})
Field Permission Introspection
Agents and frontends can ask what they're allowed to do before attempting it:
perms = Todo.get_field_permissions()
# {"visible": ["id", "title", ...], "editable": ["title", ...], "creatable": ["title", ...]}
Frontends use this to render forms, only showing fields the user can edit. Agents use it to know what data they can provide.
Hooks
Pre-hooks run before validation. Post-hooks run after. Use them to inject server-side data (tenant, created_by, generated IDs) without exposing those fields to clients.
def set_created_by(data, request=None):
"""Pre-hook: inject the current user before validation."""
if request and hasattr(request, "user"):
data["created_by"] = request.user.pk
return data
def generate_order_number(data, request=None):
"""Post-hook: generate a unique order number after validation."""
if not data.get("order_number"):
data["order_number"] = f"ORD-{uuid.uuid4().hex[:8].upper()}"
return data
registry.register(
Order,
ModelConfig(
model=Order,
permissions=[IsAuthenticatedPermission],
pre_hooks=[set_created_by],
post_hooks=[generate_order_number],
),
)
Pre-hooks can add any DB field, even ones not in the user's editable_fields. User input is filtered to allowed fields first, then hooks run. In DEBUG mode, hooks are validated for correctness at startup.
Signals
Django's built-in signals (post_save, pre_delete, etc.) fire normally because StateZero mutations go through the ORM. But Django doesn't emit signals for bulk operations. StateZero fills that gap for bulk operations performed through StateZero:
from django.dispatch import receiver
from statezero.adaptors.django.signals import post_bulk_create, post_bulk_update, post_bulk_delete
@receiver(post_bulk_create, sender=Todo)
def handle_bulk_create(sender, instances, **kwargs):
for todo in instances:
schedule_notification(todo)
@receiver(post_bulk_delete, sender=Todo)
def handle_bulk_delete(sender, pks, **kwargs):
AuditLog.objects.bulk_create([
AuditLog(action="delete", object_id=pk) for pk in pks
])
You can also trigger these signals manually from your own code:
from statezero.adaptors.django.signals import notify_bulk_created, notify_bulk_deleted
objs = Todo.objects.bulk_create([...])
notify_bulk_created(objs)
Schema Discovery
GET /statezero/models/ lists all registered models. GET /statezero/<model>/get-schema/ returns the full schema with field types, relationships, and constraints. GET /statezero/actions-schema/ returns all action schemas. Schema checksums enable efficient client-side caching.
Django Settings
INSTALLED_APPS = ["statezero.adaptors.django", ...]
# Real-time events
STATEZERO_PUSHER = {
"app_id": "...", "key": "...", "secret": "...", "cluster": "...",
}
# Schema sync token for production
STATEZERO_SYNC_TOKEN = "your-secret-token"
# Pagination
STATEZERO_DEFAULT_LIMIT = 100
# Unknown write fields: "ignore" (default) or "error"
STATEZERO_EXTRA_FIELDS = "ignore"
# View-level access gate
STATEZERO_VIEW_ACCESS_CLASS = "rest_framework.permissions.IsAuthenticated"
Testing
Frontend Tests
# tests/settings.py
STATEZERO_TEST_MODE = True
MIDDLEWARE = [..., "statezero.adaptors.django.testing.TestSeedingMiddleware", ...]
STATEZERO_TEST_STARTUP_HOOK = "myapp.test_utils.create_test_user"
python manage.py statezero_testserver --addrport 8000
import { setupTestStateZero, seedRemote, resetRemote } from "@statezero/core/testing";
const testHeaders = setupTestStateZero({ apiUrl: "http://localhost:8000/statezero", ... });
await resetRemote(testHeaders, () => Todo.remote.delete());
await seedRemote(testHeaders, () => Todo.remote.create({ title: "Seeded" }));
Python Client Tests
from statezero.client.testing import DjangoTestTransport
configure(transport=DjangoTestTransport(user=test_user))
Extensions
- django-money, MoneyField serialization
- django-simple-history, historical record events
- django-pydantic-field, Pydantic model fields
- Custom field serializers, register via
CUSTOM_FIELD_SERIALIZERS
Production & Contributing
StateZero is actively maintained and used in production, powering a real-time AI-native property orchestration platform. Comprehensive documentation is available at statezero.dev.
New features and pull requests are welcome, as long as they fit within the philosophy of unified state with the batteries needed for AI-native apps. Workflows, conversations, and event systems are out of scope. These are handled by a separate internal library.
Links
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file statezero-0.1.0b91.tar.gz.
File metadata
- Download URL: statezero-0.1.0b91.tar.gz
- Upload date:
- Size: 158.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5c91bc46db2800b309d9dbf91e7d06a622315b3530edafe6b31479ed94b3ed95
|
|
| MD5 |
54461ed1dfc9e6a97f492160d064d018
|
|
| BLAKE2b-256 |
7715c09d16e77dfe2fe31a4cad866090fea872afd85753f420c6d7288c20316f
|
File details
Details for the file statezero-0.1.0b91-py3-none-any.whl.
File metadata
- Download URL: statezero-0.1.0b91-py3-none-any.whl
- Upload date:
- Size: 151.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
32230430a8034747fd5fc99d70fd41f998e21d54e1bbf92dc8d46344e39c41ba
|
|
| MD5 |
9f873de3cdee733ba18576da39b29b65
|
|
| BLAKE2b-256 |
ee6c1af9e21b9629e2f9604ea0126fd5db1f57f523cdffdb7245424f5e0bb915
|