# obop: full text > obop is an open-source business platform: CRM, sales, invoicing and more on one data model, with AI agents that propose and humans who approve, and a marketplace of modules. # One business. One platform. **One business. One platform.** CRM, sales, invoicing and more on a single data model. Built for people and for AI agents, with the permissions and privacy to trust both. - [Get your own instance](/app/) - [See the apps](/apps/) ## All your business apps. One database. Every app shares the same contacts, products and permissions, so nothing needs gluing together. Five apps are built in; the other 22 install from the marketplace. - [CRM](/apps/crm/): Leads and pipeline (Available) - [Sales](/apps/sales/): Quotations and orders (Available) - [Invoicing](/apps/invoicing/): Drafts, posting, numbering (Available) - [Products](/apps/products/): Prices, SKUs, cost (Available) - [Contacts](/apps/contacts/): People and companies (Available) - [Project](/apps/projects/): Projects and tasks (Marketplace) - [Helpdesk](/apps/helpdesk/): Support tickets (Marketplace) - [Fleet](/apps/fleet/): Vehicles and drivers (Marketplace) - [Inventory](/apps/inventory/): Stock and warehouses (Marketplace) - [Purchase](/apps/purchase/): Vendors and purchase orders (Marketplace) - [Manufacturing](/apps/manufacturing/): Bills of materials (Marketplace) - [HR](/apps/hr/): Employees and leave (Marketplace) - [Timesheets](/apps/timesheets/): Time and billing (Marketplace) - [Planning](/apps/planning/): Shifts and resources (Marketplace) - [Field Service](/apps/field-service/): Jobs on site (Marketplace) - [Point of Sale](/apps/pos/): Retail and restaurants (Marketplace) - [eCommerce](/apps/ecommerce/): Online store (Marketplace) - [Website](/apps/website/): Pages and forms (Marketplace) - [Email Marketing](/apps/email-marketing/): Campaigns (Marketplace) - [Documents](/apps/documents/): Files and folders (Marketplace) - [Sign](/apps/sign/): E-signatures (Marketplace) - [Subscriptions](/apps/subscriptions/): Recurring billing (Marketplace) - [Dashboards](/apps/dashboards/): Reports and charts (Marketplace) - [Accounting](/apps/accounting/): Ledger and reports (Marketplace) - [Knowledge](/apps/knowledge/): Articles and guides (Marketplace) - [Appointments](/apps/appointments/): Bookable resources (Marketplace) - [Expenses](/apps/expenses/): Claims and approvals (Marketplace) ## Built for the way work happens now People and agents work in the same records, under the same rules. Nothing is bolted on afterwards. ### Agents are first-class Every action is available on the REST API, as an MCP tool and on the command line, from the same definition. An agent gets exactly the permissions you grant, never more than its owner. *Agents propose. Humans approve.* ### Sensitive data stays private Every field has a data class. Agents see masked values or nothing at all for personal and financial data, and can write tokens back without ever seeing the real value. *Masking by default* ### One data model Apps are modules on one registry, so a contact is the same contact in CRM, sales and invoicing. Permissions, audit and the API come with every model you add. *PostgreSQL underneath* ### How an agent works with you 1. **It asks.** An agent calls an action with a token scoped by its owner. 2. **It sees what it may see.** Personal and financial fields arrive masked, or not at all. 3. **It proposes.** Sensitive actions become a plan that changes nothing yet. 4. **A human approves.** The change is made only when a person says yes. ## Install what you need. Upload what you build. Admins add modules in a click from the marketplace, or upload their own package. A module describes its models, fields and workflow in one file, and instantly gets the API, the agent tools, the permissions and the privacy rules like everything else. - One-click install, upgrade and removal, with your data kept - Custom modules for a single customer, reusing a shared core - Checked on install: every field needs a data class and a permission group ## Get running in three steps 1. **Sign up.** Create an account with your email. 2. **Get your instance.** Your own database and address, ready in minutes. 3. **Add modules.** Sign in with the same login and install the apps you need. ## Open source, and yours to host obop is written in Rust and runs anywhere PostgreSQL does. Use the hosted service, or run the same program yourself with Docker. Either way each business has its own database, and the data is yours. - **Hosted by us.** We create an instance with its own database for you. Everything is free during the beta. - **Self-hosted.** One binary or one Docker image, SQLite for trying it out, PostgreSQL for real use. - **Source on GitHub.** Read the code, the decisions and the roadmap. The licence is being finalised; see the repository. ## Simple pricing, planned Self-hosting the open-source edition is free. Hosted plans are per organisation. These are planned prices; nothing is charged during the beta. - **Free**: €0. Your own hosted instance to try the invoicing basics. - **Team**: €49/month. The full seven-app loop for a growing team. - **Growth**: €249/month. More capacity, support and service levels. - **Scale**: from €899/month. Dedicated capacity and a named contact. *Planned prices, billed yearly, excluding VAT. Not yet charged.* [See what each plan includes](/pricing/) ## Questions ### What is obop? obop is an open-source business platform. It gives a company CRM, sales, invoicing, contacts and products on one data model, and lets you install more apps from a marketplace. Every action is available to people in the browser and to AI agents over REST, MCP and the command line. ### Is obop ready for production use? Not yet. obop is in beta. The core works and is tested, but there are no data migrations between module versions yet, nothing backs up the hosted databases, and many bundled apps are deliberately simple. Use it to evaluate and for non-critical work for now. The roadmap page lists what is built and what is not. ### How much does it cost? Self-hosting is free. Hosted plans are planned at €0, €49, €249 and from €899 a month per organisation, but nothing is charged during the beta and these prices may change. ### Where does my data live? Each business gets its own database, in PostgreSQL for hosted and production use. If you self-host, it lives wherever you run it. Records are stored as JSON documents until the schema manager exists. ### How do AI agents work with obop? You create a scoped agent token. The agent connects over MCP, REST or the CLI. It can only use the permission groups you gave it, sees personal data masked, and can only propose irreversible steps such as posting an invoice; a person approves them. ### Can an agent read my customers' personal data? Not in clear. Personal data comes back as stable placeholder tokens such as EMAIL_1, and financial, HR and special-category fields are withheld entirely. The agent can pass a token back to refer to a value without ever seeing it. ### Can I build or add my own apps? Yes. An app is one text file (obop.toml) that declares models, fields, data classes, permission groups and workflow actions. Admins can upload it or install it from the marketplace, and it instantly gets the API, agent tools and screens. It cannot run code yet. ### Is obop open source? The source code is public on GitHub. The final licence is still being decided (the project proposes the AGPL), so check the repository for the current terms. ## One business, one platform. Open source, built in Rust, runs anywhere PostgreSQL does. [Get your own instance](/app/) Source: https://obop-platform.sliplane.app/ --- # Apps Every app is a module on one data model. Five are built in; the other 22 install from the marketplace in a click. Open an app to see its models, fields, workflows and what an AI agent may do with it. ## Foundation - [Products](/apps/products/): Prices, SKUs, cost (Available) - [Contacts](/apps/contacts/): People and companies (Available) ## Customers - [CRM](/apps/crm/): Leads and pipeline (Available) - [Sales](/apps/sales/): Quotations and orders (Available) - [Helpdesk](/apps/helpdesk/): Support tickets (Marketplace) - [Subscriptions](/apps/subscriptions/): Recurring billing (Marketplace) - [Appointments](/apps/appointments/): Bookable resources (Marketplace) ## Finance - [Invoicing](/apps/invoicing/): Drafts, posting, numbering (Available) - [Accounting](/apps/accounting/): Ledger and reports (Marketplace) - [Expenses](/apps/expenses/): Claims and approvals (Marketplace) ## Operations - [Fleet](/apps/fleet/): Vehicles and drivers (Marketplace) - [Inventory](/apps/inventory/): Stock and warehouses (Marketplace) - [Purchase](/apps/purchase/): Vendors and purchase orders (Marketplace) - [Manufacturing](/apps/manufacturing/): Bills of materials (Marketplace) - [Field Service](/apps/field-service/): Jobs on site (Marketplace) - [Point of Sale](/apps/pos/): Retail and restaurants (Marketplace) - [eCommerce](/apps/ecommerce/): Online store (Marketplace) ## People and projects - [Project](/apps/projects/): Projects and tasks (Marketplace) - [HR](/apps/hr/): Employees and leave (Marketplace) - [Timesheets](/apps/timesheets/): Time and billing (Marketplace) - [Planning](/apps/planning/): Shifts and resources (Marketplace) ## Content and documents - [Website](/apps/website/): Pages and forms (Marketplace) - [Email Marketing](/apps/email-marketing/): Campaigns (Marketplace) - [Documents](/apps/documents/): Files and folders (Marketplace) - [Sign](/apps/sign/): E-signatures (Marketplace) - [Dashboards](/apps/dashboards/): Reports and charts (Marketplace) - [Knowledge](/apps/knowledge/): Articles and guides (Marketplace) Source: https://obop-platform.sliplane.app/apps/ --- # Pricing Self-hosting the open-source edition is free. Hosted plans are per organisation, not per user. These are planned prices: nothing is charged during the beta, and they may change before we start charging. *Planned, not yet charged* ## Free: €0 Your own hosted instance to try the invoicing basics. - Invoicing bundle - Community support - Full data export planned ## Team: €49/month The full seven-app loop for a growing team. - Five full users - All loop apps - Backups and EU region - Email support ## Growth: €249/month More capacity, support and service levels. - 25 full users - All apps, 100 GB - Point-in-time recovery, staging - Next-business-day support ## Scale: from €899/month Dedicated capacity and a named contact. - 100 or more users - Dedicated cell, region pinning - SAML and SCIM - 99.9 percent target, 24x5 Prices are planned, per organisation per month, billed yearly, excluding VAT. They are hypotheses from the product plan and are not a contract. ## Self-hosting is free The open-source edition has every bundled app, the REST API, MCP and the CLI. You run it yourself on SQLite or PostgreSQL. Paid support and services for self-hosters are planned but not yet offered. ## Pricing questions ### Am I charged anything today? No. The hosted service is a free beta. When billing starts we will announce it and nothing will be charged without your consent. ### Why per organisation and not per user? The plan is to price the business, not each seat, banded by full users. Portal users and a few accountant seats are planned to be free. ### Can I leave and take my data? Yes when you self-host: the database is yours. A full export on every plan is a binding product decision; the export tooling is still to be built. Source: https://obop-platform.sliplane.app/pricing/ --- # CRM ## What it is for Keep track of prospective deals from first contact to won or lost. A lead has a title, a contact, an expected revenue and a stage, and moving it along the pipeline is a named action that the team and its agents can both run. ## What the package does and does not do Leads and the sales pipeline. ## Models ### `lead` A prospective deal with a contact Read group: `crm.user`. Write group: `crm.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `crm.user` | in clear | Short description of the opportunity | | `contact_name` | text | `public_directory` | yes | `crm.user` | masked | Name of the contact person | | `contact_email` | text | `public_directory` | yes | `crm.user` | masked | Email of the contact person | | `expected_revenue_minor` | money | `none` | no | `crm.user` | in clear | Expected revenue in minor units | | `stage` | select (new, qualified, won, lost) | `none` | no | `crm.user` | in clear | Pipeline stage: new, qualified, won or lost | ## Workflows ### `lead` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `lead.qualify` | stage: new → qualified | `crm.user` | no | | `lead.mark_won` | stage: * → won | `crm.user` | no | | `lead.mark_lost` | stage: * → lost | `crm.user` | no | | `lead.reopen` | stage: * → new | `crm.user` | no | ## Permission groups Groups used by this app: - `crm.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `lead.delete`. Personal data is masked as placeholder tokens: `lead.contact_name`, `lead.contact_email`. ## Dependencies - `base` ## Install Built in. It is available in every instance and cannot be removed. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/crm/ --- # Sales ## What it is for Write quotations for customers and turn them into confirmed orders. Orders point at a contact, carry a total and a currency, and confirmed orders are what Invoicing bills. ## What the package does and does not do Quotations and confirmed orders for customers. ## Models ### `sale_order` A quotation or confirmed order for a customer Read group: `sales.user`. Write group: `sales.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `party_id` | ref → party | `none` | no | `sales.user` | in clear | Customer party id | | `state` | select (draft, confirmed, cancelled) | `none` | no | `sales.user` | in clear | Lifecycle: draft, confirmed or cancelled | | `total_minor` | money | `none` | no | `sales.user` | in clear | Order total in minor units | | `currency` | text | `none` | no | `sales.user` | in clear | ISO 4217 currency code | ## Workflows ### `sale_order` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `sale_order.confirm` | state: draft → confirmed | `sales.user` | no | | `sale_order.cancel` | state: * → cancelled | `sales.user` | no | | `sale_order.reset_to_draft` | state: cancelled → draft | `sales.user` | no | ## Permission groups Groups used by this app: - `sales.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `sale_order.delete`. ## Dependencies - `base` ## Install Built in. It is available in every instance and cannot be removed. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/sales/ --- # Invoicing ## What it is for Prepare customer invoices as drafts and post them to give each one its gapless legal number. Posting is the one step in the built-in apps that always needs a person: an agent can prepare the draft and propose the posting, and a human with the right permission approves it. ## What the package does and does not do Customer invoices: drafts, posting with a gapless legal number, cancellation. ## Models ### `invoice` A customer invoice; posted invoices are corrected only by credit notes Read group: `invoicing.user`. Write group: `invoicing.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `sale_order_id` | ref → sale_order | `none` | no | `invoicing.user` | in clear | Source sale order id | | `state` | select (draft, posted, cancelled) | `none` | no | `invoicing.user` | in clear | Lifecycle: draft, posted or cancelled | | `total_minor` | money | `none` | no | `invoicing.user` | in clear | Invoice total in minor units | | `number` | text | `none` | no | `invoicing.user` | in clear | Gapless legal number, set when posted | ## Workflows ### `invoice` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `invoice.post` | state: draft → posted | `invoicing.manager` | yes | | `invoice.cancel` | state: draft → cancelled | `invoicing.user` | no | ## Permission groups Groups used by this app: - `invoicing.manager` - `invoicing.user` ## Approval steps Steps an agent can only propose: - `invoice.post` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `invoice.post`, `invoice.delete`. ## Dependencies - `base` - `sales` ## Install Built in. It is available in every instance and cannot be removed. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/invoicing/ --- # Products ## What it is for The catalogue of what you sell: name, SKU, list price and unit cost. Cost is visible only to people in the cost viewer group, so a sales assistant or an agent can quote without seeing margins. ## What the package does and does not do Goods and services the business sells, with SKU, list price and cost. ## Models ### `product` A good or service the business sells Read group: `product.user`. Write group: `product.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `product.user` | in clear | Product name | | `sku` | text | `none` | no | `product.user` | in clear | Stock keeping unit | | `list_price_minor` | money | `none` | no | `product.user` | in clear | List price in minor currency units | | `cost_minor` | money | `none` | no | `product.cost_viewer` | in clear | Unit cost in minor currency units | ## Workflows This app has no workflow actions; records are created and edited directly. ## Permission groups Groups used by this app: - `product.cost_viewer` - `product.manager` - `product.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `product.delete`. ## Dependencies None. ## Install Built in. It is available in every instance and cannot be removed. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/products/ --- # Contacts ## What it is for One record for every person and organisation you deal with, shared by all other apps. Names and emails are personal data and are masked for agents; the tax identifier is withheld from them entirely. A dedicated, approval-gated erase action anonymises a contact in place. ## What the package does and does not do People and organisations the business deals with: the shared party record that CRM, Sales and Invoicing refer to. ## Models ### `party` A person or organisation the business deals with Read group: `base.user`. Write group: `base.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `public_directory` | yes | `base.user` | masked | Display name | | `email` | text | `public_directory` | yes | `base.user` | masked | Work email address | | `tax_id` | text | `financial` | yes | `base.user` | withheld | Tax identifier of a natural person | | `notes` | longtext | `internal` | yes | `base.user` | masked | Free-text internal notes | ## Workflows ### `party` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `party.erase` | - | `privacy.officer` | yes | ## Permission groups Groups used by this app: - `base.user` - `privacy.officer` ## Approval steps Steps an agent can only propose: - `party.erase` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `party.erase`, `party.delete`. Personal data is masked as placeholder tokens: `party.name`, `party.email`, `party.notes`. These fields are withheld from agents entirely: `party.tax_id`. ## Dependencies None. ## Install Built in. It is available in every instance and cannot be removed. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/contacts/ --- # Project ## What it is for Plan work as projects, milestones and tasks with assignees and deadlines. Progress is tracked by status, which keeps the model simple; it is a good fit for small teams that want a shared board rather than effort accounting. ## What the package does and does not do Projects, milestones and tasks with assignees, deadlines and status flows. Progress is tracked by status, not computed from task effort. ## Models ### `projects_project` A body of work with a goal Read group: `projects.user`. Write group: `projects.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `projects.user` | in clear | Project name | | `code` | text | `none` | no | `projects.user` | in clear | Short reference used in reports, for example WEB-24 | | `project_manager` | text | `internal` | yes | `projects.user` | masked | Name of the person accountable for the project | | `start_on` | date | `none` | no | `projects.user` | in clear | Planned start date (YYYY-MM-DD) | | `deadline` | date | `none` | no | `projects.user` | in clear | Date the project must be finished (YYYY-MM-DD) | | `budget` | money | `none` | no | `projects.user` | in clear | Agreed budget for the project (minor currency units, e.g. cents) | | `description` | longtext | `none` | no | `projects.user` | in clear | Goal and scope of the project | | `state` | select (planned, active, done) | `none` | no | `projects.user` | in clear | Project status: planned, active, done | ### `projects_milestone` A checkpoint in a project Read group: `projects.user`. Write group: `projects.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `project_id` | ref → projects_project | `none` | no | `projects.user` | in clear | The project the milestone belongs to | | `name` | text | `none` | no | `projects.user` | in clear | Milestone name, for example Design approved | | `due_on` | date | `none` | no | `projects.user` | in clear | Date the milestone is due (YYYY-MM-DD) | | `description` | longtext | `none` | no | `projects.user` | in clear | What must be true for the milestone to count as reached | | `state` | select (pending, reached, missed) | `none` | no | `projects.user` | in clear | Milestone status: pending, reached, missed | ### `projects_task` A unit of work inside a project Read group: `projects.user`. Write group: `projects.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `projects.user` | in clear | What needs doing | | `project_id` | ref → projects_project | `none` | no | `projects.user` | in clear | The project this task belongs to; empty for a private task | | `milestone_id` | ref → projects_milestone | `none` | no | `projects.user` | in clear | The milestone this task contributes to | | `assignee` | text | `internal` | yes | `projects.user` | masked | Name of the person responsible | | `priority` | select (low, normal, high) | `none` | no | `projects.user` | in clear | How important the task is: low, normal, high | | `due_on` | date | `none` | no | `projects.user` | in clear | Date the task is due (YYYY-MM-DD) | | `estimate_minutes` | number | `none` | no | `projects.user` | in clear | Planned effort in minutes | | `description` | longtext | `none` | no | `projects.user` | in clear | Details, acceptance criteria and links | | `state` | select (todo, doing, done, cancelled) | `none` | no | `projects.user` | in clear | Task status: todo, doing, done, cancelled | ## Workflows ### `projects_project` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `projects_project.start` | state: planned → active | `projects.manager` | no | | `projects_project.finish` | state: active → done | `projects.manager` | no | | `projects_project.reopen` | state: done → active | `projects.manager` | no | ### `projects_milestone` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `projects_milestone.reach` | state: pending → reached | `projects.manager` | no | | `projects_milestone.miss` | state: pending → missed | `projects.manager` | no | ### `projects_task` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `projects_task.begin` | state: todo → doing | `projects.user` | no | | `projects_task.complete` | state: doing → done | `projects.user` | no | | `projects_task.cancel` | state: todo → cancelled | `projects.user` | no | ## Permission groups Groups used by this app: - `projects.manager` - `projects.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `projects_project.delete`, `projects_milestone.delete`, `projects_task.delete`. Personal data is masked as placeholder tokens: `projects_project.project_manager`, `projects_task.assignee`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from projects ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/projects/ --- # Helpdesk ## What it is for Receive and work through customer support requests with priority, severity and service-level targets. A typical use is an agent that reads new tickets and drafts a first triage while people keep ownership of the replies. ## What the package does and does not do Support tickets with priority, severity, service-level targets and an open, in progress, resolved and closed flow. Targets are recorded, not enforced or timed automatically. ## Models ### `helpdesk_sla` A service-level policy: how fast tickets of a priority should be answered and solved Read group: `helpdesk.agent`. Write group: `helpdesk.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `helpdesk.agent` | in clear | Policy name, for example Urgent in 4 hours | | `priority` | select (low, normal, high, urgent) | `none` | no | `helpdesk.agent` | in clear | Ticket priority the policy applies to: low, normal, high, urgent | | `first_response_minutes` | number | `none` | no | `helpdesk.agent` | in clear | Target time to the first reply, in minutes | | `resolution_minutes` | number | `none` | no | `helpdesk.agent` | in clear | Target time to resolve the ticket, in minutes | | `active` | bool | `none` | no | `helpdesk.agent` | in clear | Whether the policy can be attached to new tickets | ### `helpdesk_ticket` A customer support request Read group: `helpdesk.agent`. Write group: `helpdesk.agent`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `subject` | text | `none` | no | `helpdesk.agent` | in clear | One-line summary of the problem | | `description` | longtext | `none` | no | `helpdesk.agent` | in clear | Full description of the problem and what the customer tried | | `requester_name` | text | `internal` | yes | `helpdesk.agent` | masked | Name of the person asking for help | | `requester_email` | text | `internal` | yes | `helpdesk.agent` | masked | Email address of the person asking for help | | `category` | select (question, incident, problem, feature_request) | `none` | no | `helpdesk.agent` | in clear | What kind of request it is: question, incident, problem, feature_request | | `priority` | select (low, normal, high, urgent) | `none` | no | `helpdesk.agent` | in clear | How soon it should be handled: low, normal, high, urgent | | `severity` | select (minor, major, critical) | `none` | no | `helpdesk.agent` | in clear | How badly the customer is affected: minor, major, critical | | `assignee` | text | `internal` | yes | `helpdesk.agent` | masked | Name of the agent working on the ticket | | `sla_id` | ref → helpdesk_sla | `none` | no | `helpdesk.agent` | in clear | Service-level policy the ticket is measured against | | `opened_on` | date | `none` | no | `helpdesk.agent` | in clear | Date the ticket was received (YYYY-MM-DD) | | `first_response_on` | date | `none` | no | `helpdesk.agent` | in clear | Date of the first reply to the customer (YYYY-MM-DD) | | `due_on` | date | `none` | no | `helpdesk.agent` | in clear | Date by which it should be solved under the policy (YYYY-MM-DD) | | `resolved_on` | date | `none` | no | `helpdesk.agent` | in clear | Date the problem was solved (YYYY-MM-DD) | | `resolution` | longtext | `none` | no | `helpdesk.agent` | in clear | How the problem was solved | | `state` | select (open, in_progress, resolved, closed) | `none` | no | `helpdesk.agent` | in clear | Ticket status: open, in_progress, resolved, closed | ## Workflows ### `helpdesk_ticket` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `helpdesk_ticket.take` | state: open → in_progress | `helpdesk.agent` | no | | `helpdesk_ticket.resolve` | state: in_progress → resolved | `helpdesk.agent` | no | | `helpdesk_ticket.close` | state: resolved → closed | `helpdesk.manager` | yes | | `helpdesk_ticket.reopen` | state: resolved → open | `helpdesk.agent` | no | ## Permission groups Groups used by this app: - `helpdesk.agent` - `helpdesk.manager` ## Approval steps Steps an agent can only propose: - `helpdesk_ticket.close` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `helpdesk_ticket.close`, `helpdesk_sla.delete`, `helpdesk_ticket.delete`. Personal data is masked as placeholder tokens: `helpdesk_ticket.requester_name`, `helpdesk_ticket.requester_email`, `helpdesk_ticket.assignee`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from helpdesk ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/helpdesk/ --- # Fleet ## What it is for Keep a register of company vehicles, who drives them and their service history. It records status and services; it does not compute running costs. ## What the package does and does not do Vehicles, drivers and service history for a small fleet. Tracks status and service records; it does not compute running costs or fuel use. ## Models ### `fleet_vehicle` A vehicle the business operates Read group: `fleet.user`. Write group: `fleet.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `plate` | text | `none` | no | `fleet.user` | in clear | Registration plate | | `model` | text | `none` | no | `fleet.user` | in clear | Make and model, for example Renault Master | | `vin` | text | `none` | no | `fleet.user` | in clear | Vehicle identification number (chassis number) | | `fuel` | select (petrol, diesel, hybrid, electric, other) | `none` | no | `fleet.user` | in clear | Fuel or drive type: petrol, diesel, hybrid, electric, other | | `odometer_km` | number | `none` | no | `fleet.user` | in clear | Last known odometer reading in kilometres | | `acquired_on` | date | `none` | no | `fleet.user` | in clear | Date the vehicle entered the fleet (YYYY-MM-DD) | | `driver` | text | `internal` | yes | `fleet.user` | masked | Name of the driver currently using the vehicle, as free text | | `driver_id` | ref → fleet_vehicle_driver | `none` | no | `fleet.user` | in clear | Driver record currently assigned to the vehicle | | `state` | select (available, in_service, maintenance, retired) | `none` | no | `fleet.user` | in clear | Availability: available, in_service, maintenance, retired | ### `fleet_vehicle_driver` A person authorised to drive fleet vehicles Read group: `fleet.user`. Write group: `fleet.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `public_directory` | yes | `fleet.user` | masked | Full name of the driver | | `phone` | text | `internal` | yes | `fleet.user` | masked | Work phone number of the driver | | `licence_number` | text | `confidential_hr` | yes | `fleet.manager` | withheld | Driving licence number | | `licence_expiry` | date | `confidential_hr` | yes | `fleet.manager` | withheld | Date the driving licence expires (YYYY-MM-DD) | | `state` | select (active, suspended) | `none` | no | `fleet.user` | in clear | Driver status: active, suspended | ### `fleet_vehicle_service` A maintenance, repair or inspection job on a vehicle Read group: `fleet.user`. Write group: `fleet.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `vehicle_id` | ref → fleet_vehicle | `none` | no | `fleet.user` | in clear | The vehicle serviced | | `kind` | select (maintenance, repair, inspection, tyres, other) | `none` | no | `fleet.user` | in clear | Type of job: maintenance, repair, inspection, tyres, other | | `service_on` | date | `none` | no | `fleet.user` | in clear | Date of the job (YYYY-MM-DD) | | `odometer_km` | number | `none` | no | `fleet.user` | in clear | Odometer reading in kilometres when serviced | | `cost` | money | `none` | no | `fleet.user` | in clear | Total invoiced by the workshop (minor currency units, e.g. cents) | | `workshop` | text | `none` | no | `fleet.user` | in clear | Name of the workshop or garage | | `notes` | longtext | `none` | no | `fleet.user` | in clear | What was done or found | | `state` | select (planned, done, cancelled) | `none` | no | `fleet.user` | in clear | Job status: planned, done, cancelled | ## Workflows ### `fleet_vehicle` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `fleet_vehicle.dispatch` | state: available → in_service | `fleet.manager` | no | | `fleet_vehicle.return_to_pool` | state: in_service → available | `fleet.manager` | no | | `fleet_vehicle.retire` | state: available → retired | `fleet.approver` | yes | ### `fleet_vehicle_driver` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `fleet_vehicle_driver.suspend` | state: active → suspended | `fleet.manager` | no | | `fleet_vehicle_driver.reactivate` | state: suspended → active | `fleet.manager` | no | ### `fleet_vehicle_service` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `fleet_vehicle_service.complete` | state: planned → done | `fleet.user` | no | | `fleet_vehicle_service.cancel` | state: planned → cancelled | `fleet.user` | no | ## Permission groups Groups used by this app: - `fleet.approver` - `fleet.manager` - `fleet.user` ## Approval steps Steps an agent can only propose: - `fleet_vehicle.retire` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `fleet_vehicle.retire`, `fleet_vehicle.delete`, `fleet_vehicle_driver.delete`, `fleet_vehicle_service.delete`. Personal data is masked as placeholder tokens: `fleet_vehicle.driver`, `fleet_vehicle_driver.name`, `fleet_vehicle_driver.phone`. These fields are withheld from agents entirely: `fleet_vehicle_driver.licence_number`, `fleet_vehicle_driver.licence_expiry`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from fleet ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/fleet/ --- # Inventory ## What it is for Describe warehouses and locations, record stock levels and log stock moves. Quantities are entered by people, so it works as a ledger of what was counted and moved rather than an automatic stock engine. ## What the package does and does not do Warehouses, stock locations, stock levels and stock moves. Quantities are entered and validated by people; they are not updated automatically by moves. ## Models ### `inventory_warehouse` A building or site where stock is kept Read group: `inventory.user`. Write group: `inventory.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `inventory.user` | in clear | Warehouse name | | `code` | text | `none` | no | `inventory.user` | in clear | Short code printed on documents, for example WH1 | | `address` | longtext | `none` | no | `inventory.user` | in clear | Postal address of the site | | `active` | bool | `none` | no | `inventory.user` | in clear | Whether the warehouse is in use | ### `inventory_location` A place inside a warehouse where stock can sit Read group: `inventory.user`. Write group: `inventory.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `inventory.user` | in clear | Location name, for example Shelf A3 | | `warehouse_id` | ref → inventory_warehouse | `none` | no | `inventory.user` | in clear | The warehouse the location belongs to | | `kind` | select (internal, receiving, shipping, transit, scrap) | `none` | no | `inventory.user` | in clear | What the location is used for: internal, receiving, shipping, transit, scrap | | `active` | bool | `none` | no | `inventory.user` | in clear | Whether the location is in use | ### `inventory_item` The quantity of one product held at one location Read group: `inventory.user`. Write group: `inventory.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `product_id` | ref → product | `none` | no | `inventory.user` | in clear | The product held | | `location_id` | ref → inventory_location | `none` | no | `inventory.user` | in clear | Where it is held | | `quantity` | number | `none` | no | `inventory.user` | in clear | Units on hand | | `reorder_level` | number | `none` | no | `inventory.user` | in clear | Quantity at or below which the product should be replenished | | `lot` | text | `none` | no | `inventory.user` | in clear | Lot or batch number, if tracked | ### `inventory_move` A movement of a product between locations, or into or out of the business Read group: `inventory.user`. Write group: `inventory.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `reference` | text | `none` | no | `inventory.user` | in clear | Reference of the move, for example a delivery or receipt number | | `product_id` | ref → product | `none` | no | `inventory.user` | in clear | The product moved | | `from_location_id` | ref → inventory_location | `none` | no | `inventory.user` | in clear | Where it leaves; empty for a receipt from a supplier | | `to_location_id` | ref → inventory_location | `none` | no | `inventory.user` | in clear | Where it arrives; empty for a delivery to a customer | | `quantity` | number | `none` | no | `inventory.user` | in clear | Units moved | | `scheduled_on` | date | `none` | no | `inventory.user` | in clear | Date the move is planned for (YYYY-MM-DD) | | `state` | select (draft, done, cancelled) | `none` | no | `inventory.user` | in clear | Move status: draft, done, cancelled | ## Workflows ### `inventory_move` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `inventory_move.validate` | state: draft → done | `inventory.user` | yes | | `inventory_move.cancel` | state: draft → cancelled | `inventory.user` | no | ## Permission groups Groups used by this app: - `inventory.manager` - `inventory.user` ## Approval steps Steps an agent can only propose: - `inventory_move.validate` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `inventory_move.validate`, `inventory_warehouse.delete`, `inventory_location.delete`, `inventory_item.delete`, `inventory_move.delete`. ## Dependencies - `product` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from inventory ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/inventory/ --- # Purchase ## What it is for Keep vendors and the purchase orders you send them, with order lines and a draft to received flow. Totals are entered, not summed from the lines. ## What the package does and does not do Vendors, purchase orders and order lines with a draft, sent, confirmed, received and cancelled flow. Order totals are entered, not summed from the lines. ## Models ### `purchase_vendor` Purchasing terms for a contact you buy from Read group: `purchase.user`. Write group: `purchase.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `party_id` | ref → party | `none` | no | `purchase.user` | in clear | The contact record of the vendor | | `vendor_reference` | text | `none` | no | `purchase.user` | in clear | Your customer number at this vendor | | `payment_terms_days` | number | `none` | no | `purchase.user` | in clear | Days allowed to pay an invoice | | `lead_time_days` | number | `none` | no | `purchase.user` | in clear | Usual days between order and delivery | | `iban` | text | `financial` | yes | `purchase.manager` | withheld | Bank account the vendor is paid into | | `active` | bool | `none` | no | `purchase.user` | in clear | Whether you still buy from this vendor | ### `purchase_order` A request to a vendor to supply goods or services Read group: `purchase.user`. Write group: `purchase.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `reference` | text | `none` | no | `purchase.user` | in clear | Order number printed on the document | | `vendor_id` | ref → purchase_vendor | `none` | no | `purchase.user` | in clear | The vendor ordered from | | `ordered_on` | date | `none` | no | `purchase.user` | in clear | Date the order was placed (YYYY-MM-DD) | | `expected_on` | date | `none` | no | `purchase.user` | in clear | Date the goods are expected (YYYY-MM-DD) | | `currency` | text | `none` | no | `purchase.user` | in clear | ISO 4217 currency code, for example EUR | | `total` | money | `none` | no | `purchase.user` | in clear | Order total as agreed with the vendor (minor currency units, e.g. cents) | | `notes` | longtext | `none` | no | `purchase.user` | in clear | Delivery instructions and remarks for the vendor | | `state` | select (draft, sent, confirmed, received, cancelled) | `none` | no | `purchase.user` | in clear | Order status: draft, sent, confirmed, received, cancelled | ### `purchase_order_line` One product or service on a purchase order Read group: `purchase.user`. Write group: `purchase.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `order_id` | ref → purchase_order | `none` | no | `purchase.user` | in clear | The order the line belongs to | | `product_id` | ref → product | `none` | no | `purchase.user` | in clear | The product ordered | | `description` | text | `none` | no | `purchase.user` | in clear | Wording for the vendor if different from the product name | | `quantity` | number | `none` | no | `purchase.user` | in clear | Units ordered | | `unit_price` | money | `none` | no | `purchase.user` | in clear | Agreed price per unit (minor currency units, e.g. cents) | | `received_quantity` | number | `none` | no | `purchase.user` | in clear | Units received so far | ## Workflows ### `purchase_order` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `purchase_order.send` | state: draft → sent | `purchase.user` | no | | `purchase_order.confirm` | state: sent → confirmed | `purchase.manager` | yes | | `purchase_order.receive` | state: confirmed → received | `purchase.user` | no | | `purchase_order.cancel` | state: draft → cancelled | `purchase.user` | no | ## Permission groups Groups used by this app: - `purchase.manager` - `purchase.user` ## Approval steps Steps an agent can only propose: - `purchase_order.confirm` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `purchase_order.confirm`, `purchase_vendor.delete`, `purchase_order.delete`, `purchase_order_line.delete`. These fields are withheld from agents entirely: `purchase_vendor.iban`. ## Dependencies - `base` - `product` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from purchase ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/purchase/ --- # Manufacturing ## What it is for Define what a product is made of and plan manufacturing orders through draft, confirmed, in progress and done. Component stock is not consumed automatically. ## What the package does and does not do Bills of materials and manufacturing orders with a draft, confirmed, in progress and done flow. Component stock is not consumed automatically. ## Models ### `manufacturing_bom` A recipe: the components needed to make a product Read group: `manufacturing.user`. Write group: `manufacturing.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `product_id` | ref → product | `none` | no | `manufacturing.user` | in clear | The finished product the recipe makes | | `name` | text | `none` | no | `manufacturing.user` | in clear | Name of the recipe, for example Standard assembly | | `quantity` | number | `none` | no | `manufacturing.user` | in clear | Units of the finished product one run produces | | `instructions` | longtext | `none` | no | `manufacturing.user` | in clear | Assembly notes for the shop floor | | `active` | bool | `none` | no | `manufacturing.user` | in clear | Whether the recipe can be used for new orders | ### `manufacturing_bom_line` One component of a bill of materials Read group: `manufacturing.user`. Write group: `manufacturing.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `bom_id` | ref → manufacturing_bom | `none` | no | `manufacturing.user` | in clear | The recipe the component belongs to | | `component_id` | ref → product | `none` | no | `manufacturing.user` | in clear | The product consumed | | `quantity` | number | `none` | no | `manufacturing.user` | in clear | Units of the component used per run | ### `manufacturing_order` An instruction to produce a quantity of a product Read group: `manufacturing.user`. Write group: `manufacturing.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `reference` | text | `none` | no | `manufacturing.user` | in clear | Order number, for example MO-0042 | | `bom_id` | ref → manufacturing_bom | `none` | no | `manufacturing.user` | in clear | The recipe to follow | | `quantity` | number | `none` | no | `manufacturing.user` | in clear | Units of the finished product to make | | `planned_on` | date | `none` | no | `manufacturing.user` | in clear | Date production should start (YYYY-MM-DD) | | `deadline` | date | `none` | no | `manufacturing.user` | in clear | Date the goods are needed (YYYY-MM-DD) | | `responsible` | text | `internal` | yes | `manufacturing.user` | masked | Name of the person in charge of the run | | `state` | select (draft, confirmed, in_progress, done, cancelled) | `none` | no | `manufacturing.user` | in clear | Order status: draft, confirmed, in_progress, done, cancelled | ## Workflows ### `manufacturing_order` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `manufacturing_order.confirm` | state: draft → confirmed | `manufacturing.manager` | no | | `manufacturing_order.start` | state: confirmed → in_progress | `manufacturing.user` | no | | `manufacturing_order.finish` | state: in_progress → done | `manufacturing.user` | no | | `manufacturing_order.cancel` | state: draft → cancelled | `manufacturing.user` | no | ## Permission groups Groups used by this app: - `manufacturing.manager` - `manufacturing.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `manufacturing_bom.delete`, `manufacturing_bom_line.delete`, `manufacturing_order.delete`. Personal data is masked as placeholder tokens: `manufacturing_order.responsible`. ## Dependencies - `product` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from manufacturing ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/manufacturing/ --- # HR ## What it is for A register of employees, departments and leave requests, with strict data classes: pay, bank details and national id are visible to managers only and health or union data only to a special reader group. Agents never see the sensitive classes. ## What the package does and does not do Employees, departments and leave requests. Personal data is classified strictly: pay, bank and national id are visible to managers only, and health and union data to the special reader group. ## Models ### `hr_department` A team or department of the company Read group: `hr.user`. Write group: `hr.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `hr.user` | in clear | Department name | | `head` | text | `public_directory` | yes | `hr.user` | masked | Name of the department head | | `cost_centre` | text | `none` | no | `hr.user` | in clear | Cost centre code used by finance | ### `hr_employee` A person employed by the company Read group: `hr.user`. Write group: `hr.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `public_directory` | yes | `hr.user` | masked | Full name | | `job_title` | text | `public_directory` | yes | `hr.user` | masked | Job title | | `work_email` | text | `public_directory` | yes | `hr.user` | masked | Work email address | | `work_phone` | text | `public_directory` | yes | `hr.user` | masked | Work phone number | | `department_id` | ref → hr_department | `none` | no | `hr.user` | in clear | The department the person belongs to | | `reports_to_id` | ref → hr_employee | `none` | no | `hr.user` | in clear | The person's line manager | | `hired_on` | date | `internal` | yes | `hr.user` | masked | First day of employment (YYYY-MM-DD) | | `contract_type` | select (permanent, fixed_term, contractor, intern) | `confidential_hr` | yes | `hr.manager` | withheld | Type of employment contract: permanent, fixed_term, contractor, intern | | `private_email` | text | `confidential_hr` | yes | `hr.manager` | withheld | Private email address | | `private_phone` | text | `confidential_hr` | yes | `hr.manager` | withheld | Private phone number | | `home_address` | longtext | `confidential_hr` | yes | `hr.manager` | withheld | Private home address | | `birth_date` | date | `confidential_hr` | yes | `hr.manager` | withheld | Date of birth (YYYY-MM-DD) | | `national_id` | text | `confidential_hr` | yes | `hr.manager` | withheld | National identity or social security number | | `emergency_contact` | text | `confidential_hr` | yes | `hr.manager` | withheld | Name and phone of the person to call in an emergency | | `salary` | money | `financial` | yes | `hr.manager` | withheld | Gross annual salary (minor currency units, e.g. cents) | | `bank_iban` | text | `financial` | yes | `hr.manager` | withheld | Bank account salary is paid into | | `union_member` | bool | `special_category` | yes | `hr.special_reader` | withheld | Whether the person is a trade union member | | `work_permit_expiry` | date | `special_category` | yes | `hr.special_reader` | withheld | Date the work permit expires, for non-citizens (YYYY-MM-DD) | | `health_note` | longtext | `special_category` | yes | `hr.special_reader` | withheld | Health condition or disability relevant to the workplace | | `state` | select (onboarding, active, offboarded) | `none` | no | `hr.user` | in clear | Employment status: onboarding, active, offboarded | ### `hr_leave_request` A request for time off Read group: `hr.user`. Write group: `hr.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `employee_id` | ref → hr_employee | `none` | no | `hr.user` | in clear | The employee asking for time off | | `leave_kind` | select (paid, unpaid, parental, study, other) | `confidential_hr` | yes | `hr.user` | withheld | Kind of leave: paid, unpaid, parental, study, other | | `start_on` | date | `internal` | yes | `hr.user` | masked | First day of absence (YYYY-MM-DD) | | `end_on` | date | `internal` | yes | `hr.user` | masked | Last day of absence (YYYY-MM-DD) | | `days` | number | `none` | no | `hr.user` | in clear | Working days requested | | `reason` | longtext | `confidential_hr` | yes | `hr.manager` | withheld | Why the leave is requested | | `is_sick_leave` | bool | `special_category` | yes | `hr.special_reader` | withheld | Whether the absence is due to illness | | `medical_note` | longtext | `special_category` | yes | `hr.special_reader` | withheld | Reference to the medical certificate, if any | | `state` | select (draft, requested, approved, refused) | `none` | no | `hr.user` | in clear | Request status: draft, requested, approved, refused | ## Workflows ### `hr_employee` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `hr_employee.activate` | state: onboarding → active | `hr.manager` | no | | `hr_employee.offboard` | state: active → offboarded | `hr.manager` | yes | ### `hr_leave_request` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `hr_leave_request.request` | state: draft → requested | `hr.user` | no | | `hr_leave_request.approve` | state: requested → approved | `hr.manager` | yes | | `hr_leave_request.refuse` | state: requested → refused | `hr.manager` | yes | ## Permission groups Groups used by this app: - `hr.manager` - `hr.special_reader` - `hr.user` ## Approval steps Steps an agent can only propose: - `hr_employee.offboard` - `hr_leave_request.approve` - `hr_leave_request.refuse` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `hr_employee.offboard`, `hr_leave_request.approve`, `hr_leave_request.refuse`, `hr_department.delete`, `hr_employee.delete`, `hr_leave_request.delete`. Personal data is masked as placeholder tokens: `hr_department.head`, `hr_employee.name`, `hr_employee.job_title`, `hr_employee.work_email`, `hr_employee.work_phone`, `hr_employee.hired_on`, `hr_leave_request.start_on`, `hr_leave_request.end_on`. These fields are withheld from agents entirely: `hr_employee.contract_type`, `hr_employee.private_email`, `hr_employee.private_phone`, `hr_employee.home_address`, `hr_employee.birth_date`, `hr_employee.national_id`, `hr_employee.emergency_contact`, `hr_employee.salary`, `hr_employee.bank_iban`, `hr_employee.union_member`, `hr_employee.work_permit_expiry`, `hr_employee.health_note`, `hr_leave_request.leave_kind`, `hr_leave_request.reason`, `hr_leave_request.is_sick_leave`, `hr_leave_request.medical_note`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from hr ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/hr/ --- # Timesheets ## What it is for Record time spent on project tasks in minutes, mark it billable and run it through an approval step. Needs the Projects package. ## What the package does and does not do Time spent on project tasks, entered in minutes, with a billable flag and an approval step. Needs the Projects package. ## Models ### `timesheets_entry` Time one person spent on one task on one day Read group: `timesheets.user`. Write group: `timesheets.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `task_id` | ref → projects_task | `none` | no | `timesheets.user` | in clear | The task worked on | | `worker` | text | `internal` | yes | `timesheets.user` | masked | Name of the person who did the work | | `worked_on` | date | `none` | no | `timesheets.user` | in clear | Day the work was done (YYYY-MM-DD) | | `minutes` | number | `none` | no | `timesheets.user` | in clear | Time spent, in minutes | | `billable` | bool | `none` | no | `timesheets.user` | in clear | Whether the time can be charged to the customer | | `description` | text | `none` | no | `timesheets.user` | in clear | What was done | | `cost_rate` | money | `financial` | yes | `timesheets.cost_viewer` | withheld | Internal hourly cost of the worker at the time of entry (minor currency units, e.g. cents) | | `state` | select (draft, submitted, approved) | `none` | no | `timesheets.user` | in clear | Entry status: draft, submitted, approved | ## Workflows ### `timesheets_entry` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `timesheets_entry.submit` | state: draft → submitted | `timesheets.user` | no | | `timesheets_entry.approve` | state: submitted → approved | `timesheets.manager` | no | | `timesheets_entry.reject` | state: submitted → draft | `timesheets.manager` | no | ## Permission groups Groups used by this app: - `timesheets.cost_viewer` - `timesheets.manager` - `timesheets.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `timesheets_entry.delete`. Personal data is masked as placeholder tokens: `timesheets_entry.worker`. These fields are withheld from agents entirely: `timesheets_entry.cost_rate`. ## Dependencies - `projects` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from timesheets ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/timesheets/ --- # Planning ## What it is for Plan shifts for employees from reusable templates. A shift with nobody assigned is an open shift; the workflow runs from open through published to confirmed by the employee. ## What the package does and does not do Shifts for employees, with roles and reusable templates. A shift has a day and a start and end in minutes since midnight (0 to 1439; 540 is 09:00). A shift with no employee is an open shift. Workflow: open, published, confirmed by the employee. Needs the Human Resources package. ## Models ### `planning_role` A role a shift is planned for, for example cashier or electrician Read group: `planning.user`. Write group: `planning.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `planning.user` | in clear | Role name | | `note` | longtext | `none` | no | `planning.user` | in clear | What the role involves | ### `planning_template` A reusable shift pattern to copy into shifts Read group: `planning.user`. Write group: `planning.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `planning.user` | in clear | Template name, for example Morning shift | | `role_id` | ref → planning_role | `none` | no | `planning.user` | in clear | The role the template is for | | `start_minute` | number | `none` | no | `planning.user` | in clear | Start in minutes since midnight (540 is 09:00) | | `end_minute` | number | `none` | no | `planning.user` | in clear | End in minutes since midnight (1020 is 17:00) | ### `planning_shift` A slot of work on one day for one role, optionally assigned to an employee Read group: `planning.user`. Write group: `planning.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `planning.user` | in clear | Short title, for example Opening shift | | `role_id` | ref → planning_role | `none` | no | `planning.user` | in clear | The role needed | | `employee_id` | ref → hr_employee | `internal` | yes | `planning.user` | masked | Employee the shift is assigned to; empty for an open shift | | `work_date` | date | `none` | no | `planning.user` | in clear | Day of the shift (YYYY-MM-DD) | | `start_minute` | number | `none` | no | `planning.user` | in clear | Start in minutes since midnight, 0 to 1439 (540 is 09:00) | | `end_minute` | number | `none` | no | `planning.user` | in clear | End in minutes since midnight, 0 to 1439 (1020 is 17:00) | | `note` | longtext | `none` | no | `planning.user` | in clear | Instructions for whoever works the shift | | `state` | select (open, published, confirmed) | `none` | no | `planning.user` | in clear | Shift status: open, published, confirmed | ## Workflows ### `planning_shift` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `planning_shift.publish` | state: open → published | `planning.manager` | no | | `planning_shift.unpublish` | state: published → open | `planning.manager` | no | | `planning_shift.confirm` | state: published → confirmed | `planning.manager` | no | ## Permission groups Groups used by this app: - `planning.manager` - `planning.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `planning_role.delete`, `planning_template.delete`, `planning_shift.delete`. Personal data is masked as placeholder tokens: `planning_shift.employee_id`. ## Dependencies - `hr` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from planning ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/planning/ --- # Field Service ## What it is for Schedule on-site jobs for customers: who does the work, where, when, which parts were used and whether the customer must sign off. Site addresses and contacts are personal data and treated as such. ## What the package does and does not do On-site jobs for customers, done by a technician: scheduled, en route, in progress, done or cancelled. A job records the customer, the site, the technician, the planned day and start (minutes since midnight), the parts used and whether the customer must sign off. Site address and contact details are personal data. Needs the Human Resources and Products packages. ## Models ### `field_service_job` An on-site job for a customer Read group: `field_service.user`. Write group: `field_service.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `field_service.user` | in clear | Short title of the job, for example Replace boiler pump | | `customer_id` | ref → party | `none` | no | `field_service.user` | in clear | The customer the job is for | | `site_name` | text | `none` | no | `field_service.user` | in clear | Name of the site, for example Main warehouse | | `site_address` | longtext | `internal` | yes | `field_service.user` | masked | Street address where the work is done; often a person's home | | `site_city` | text | `internal` | yes | `field_service.user` | masked | City of the site | | `site_postcode` | text | `internal` | yes | `field_service.user` | masked | Postcode of the site | | `site_country` | text | `none` | no | `field_service.user` | in clear | Country of the site | | `site_contact_name` | text | `internal` | yes | `field_service.user` | masked | Name of the person to meet on site | | `site_contact_phone` | text | `internal` | yes | `field_service.user` | masked | Phone number of the person to meet on site | | `technician_id` | ref → hr_employee | `internal` | yes | `field_service.user` | masked | Technician assigned to the job | | `priority` | select (low, normal, high, urgent) | `none` | no | `field_service.user` | in clear | How urgent the job is: low, normal, high, urgent | | `description` | longtext | `none` | no | `field_service.user` | in clear | What has to be done and what the customer reported | | `scheduled_on` | date | `none` | no | `field_service.user` | in clear | Planned day of the visit (YYYY-MM-DD) | | `scheduled_start_minute` | number | `none` | no | `field_service.user` | in clear | Planned start in minutes since midnight (540 is 09:00) | | `planned_minutes` | number | `none` | no | `field_service.user` | in clear | Planned duration in minutes | | `signature_required` | bool | `none` | no | `field_service.user` | in clear | Whether the customer has to sign off the work before it counts as done | | `signed_by` | text | `internal` | yes | `field_service.user` | masked | Name of the person who signed off the work | | `signed_on` | date | `none` | no | `field_service.user` | in clear | Day the customer signed off (YYYY-MM-DD) | | `work_report` | longtext | `none` | no | `field_service.user` | in clear | What was done, written by the technician | | `state` | select (scheduled, en_route, in_progress, done, cancelled) | `none` | no | `field_service.user` | in clear | Job status: scheduled, en_route, in_progress, done, cancelled | ### `field_service_part` A part or product used on a job Read group: `field_service.user`. Write group: `field_service.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `job_id` | ref → field_service_job | `none` | no | `field_service.user` | in clear | The job the part was used on | | `product_id` | ref → product | `none` | no | `field_service.user` | in clear | The product that was used | | `quantity` | number | `none` | no | `field_service.user` | in clear | How many units were used | | `unit_price` | money | `none` | no | `field_service.user` | in clear | Price per unit charged to the customer (minor currency units, e.g. cents) | | `note` | text | `none` | no | `field_service.user` | in clear | Remark about the part, for example serial number | ## Workflows ### `field_service_job` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `field_service_job.depart` | state: scheduled → en_route | `field_service.user` | no | | `field_service_job.start` | state: en_route → in_progress | `field_service.user` | no | | `field_service_job.complete` | state: in_progress → done | `field_service.user` | no | | `field_service_job.cancel` | state: scheduled → cancelled | `field_service.manager` | no | ## Permission groups Groups used by this app: - `field_service.manager` - `field_service.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `field_service_job.delete`, `field_service_part.delete`. Personal data is masked as placeholder tokens: `field_service_job.site_address`, `field_service_job.site_city`, `field_service_job.site_postcode`, `field_service_job.site_contact_name`, `field_service_job.site_contact_phone`, `field_service_job.technician_id`, `field_service_job.signed_by`. ## Dependencies - `base` - `product` - `hr` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from field_service ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/field-service/ --- # Point of Sale ## What it is for Model a shop till: configuration, cash-controlled sessions, orders with lines and payment lines per method. It records what happened at the till; it is not a payment terminal integration, and card numbers must never be stored. ## What the package does and does not do Shop tills: a point-of-sale configuration, cash-controlled sessions that are opened and closed, orders with lines, and payment lines per method. A refund and the closing of a session need a human approval from a manager; agents may only propose them. Totals, change and cash differences are entered, not computed. Card numbers must never be stored; keep only the payment provider's reference. Needs the Products package. ## Models ### `pos_config` A point of sale (a till or a shop) and its rules Read group: `pos.user`. Write group: `pos.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `pos.user` | in clear | Name of the till or shop, for example Main street shop | | `currency` | text | `none` | no | `pos.user` | in clear | ISO 4217 currency code, for example EUR | | `receipt_header` | longtext | `none` | no | `pos.user` | in clear | Text printed at the top of receipts | | `cash_control` | bool | `none` | no | `pos.user` | in clear | Whether sessions must count the cash in the drawer when they open and close | | `default_opening_cash` | money | `none` | no | `pos.user` | in clear | Cash normally left in the drawer overnight (minor currency units) | | `allow_refunds` | bool | `none` | no | `pos.user` | in clear | Whether cashiers may propose refunds at this point of sale | | `active` | bool | `none` | no | `pos.user` | in clear | Whether the point of sale is in use | ### `pos_session` One shift of selling at a point of sale, from opening to closing the cash drawer Read group: `pos.user`. Write group: `pos.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `config_id` | ref → pos_config | `none` | no | `pos.user` | in clear | The point of sale the session belongs to | | `cashier` | text | `internal` | yes | `pos.user` | masked | Name of the cashier who opened the session | | `opened_on` | date | `none` | no | `pos.user` | in clear | Day the session was opened (YYYY-MM-DD) | | `closed_on` | date | `none` | no | `pos.user` | in clear | Day the session was closed (YYYY-MM-DD) | | `opening_cash` | money | `financial` | no | `pos.user` | withheld | Cash counted in the drawer at opening (minor currency units) | | `expected_cash` | money | `financial` | no | `pos.user` | withheld | Cash the drawer should hold at closing: opening cash plus cash payments (entered, not computed) | | `closing_cash` | money | `financial` | no | `pos.user` | withheld | Cash actually counted in the drawer at closing | | `cash_difference` | money | `financial` | no | `pos.user` | withheld | Closing cash minus expected cash; negative means money is missing | | `note` | longtext | `none` | no | `pos.user` | in clear | Explanation for any cash difference | | `state` | select (open, closed) | `none` | no | `pos.user` | in clear | Session status: open, closed | ### `pos_order` A sale rung up at a point of sale Read group: `pos.user`. Write group: `pos.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `session_id` | ref → pos_session | `none` | no | `pos.user` | in clear | The session the sale was made in | | `receipt_number` | text | `none` | no | `pos.user` | in clear | Number printed on the receipt. Free text: packages cannot generate sequences | | `ordered_on` | date | `none` | no | `pos.user` | in clear | Day of the sale (YYYY-MM-DD) | | `customer_id` | ref → party | `none` | no | `pos.user` | in clear | The customer, when one was identified | | `tax_total` | money | `none` | no | `pos.user` | in clear | Tax included in the total (minor currency units) | | `total` | money | `none` | no | `pos.user` | in clear | Total paid by the customer including tax (minor currency units, entered not computed) | | `note` | longtext | `none` | no | `pos.user` | in clear | Remark about the sale | | `state` | select (draft, paid, refunded, cancelled) | `none` | no | `pos.user` | in clear | Sale status: draft, paid, refunded, cancelled | ### `pos_order_line` One product line of a sale Read group: `pos.user`. Write group: `pos.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `order_id` | ref → pos_order | `none` | no | `pos.user` | in clear | The sale the line belongs to | | `product_id` | ref → product | `none` | no | `pos.user` | in clear | The product sold | | `description` | text | `none` | no | `pos.user` | in clear | Text shown on the receipt | | `quantity` | number | `none` | no | `pos.user` | in clear | Units sold | | `unit_price` | money | `none` | no | `pos.user` | in clear | Price per unit including tax (minor currency units) | | `discount_bp` | number | `none` | no | `pos.user` | in clear | Discount in basis points: 1000 means 10.00 percent | | `subtotal` | money | `none` | no | `pos.user` | in clear | Line total after discount (minor currency units, entered not computed) | ### `pos_payment` A payment received against a sale; a sale may be paid with several methods Read group: `pos.user`. Write group: `pos.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `order_id` | ref → pos_order | `none` | no | `pos.user` | in clear | The sale the payment is for | | `method` | select (cash, card, bank_transfer, voucher, other) | `none` | no | `pos.user` | in clear | How the customer paid: cash, card, bank_transfer, voucher, other | | `amount` | money | `financial` | no | `pos.user` | withheld | Amount paid with this method (minor currency units) | | `paid_on` | date | `none` | no | `pos.user` | in clear | Day of the payment (YYYY-MM-DD) | | `provider_reference` | text | `financial` | yes | `pos.user` | withheld | Reference from the card terminal or payment provider; never a card number | ## Workflows ### `pos_session` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `pos_session.close` | state: open → closed | `pos.manager` | yes | ### `pos_order` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `pos_order.pay` | state: draft → paid | `pos.user` | no | | `pos_order.cancel` | state: draft → cancelled | `pos.user` | no | | `pos_order.refund` | state: paid → refunded | `pos.manager` | yes | ## Permission groups Groups used by this app: - `pos.manager` - `pos.user` ## Approval steps Steps an agent can only propose: - `pos_session.close` - `pos_order.refund` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `pos_session.close`, `pos_order.refund`, `pos_config.delete`, `pos_session.delete`, `pos_order.delete`, `pos_order_line.delete`, `pos_payment.delete`. Personal data is masked as placeholder tokens: `pos_session.cashier`. These fields are withheld from agents entirely: `pos_session.opening_cash`, `pos_session.expected_cash`, `pos_session.closing_cash`, `pos_session.cash_difference`, `pos_payment.amount`, `pos_payment.provider_reference`. ## Dependencies - `base` - `product` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from pos ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/pos/ --- # eCommerce ## What it is for Store the data of an online shop: listings, shipping methods, discount codes and web orders with a fulfilment flow. Refunds need a manager and an approval. A storefront or other client reads and writes this data through the API. ## What the package does and does not do An online shop: categories, product listings with translations, shipping methods, discount codes, web orders with lines and a fulfilment flow of pending, paid, shipped, delivered, cancelled and refunded. When the instance runs with the public site enabled, visitors browse the published listings and place orders without an account; the server prices every order from the published listing and never trusts the browser. There is no payment provider: an order starts as pending and the shop contacts the buyer. Refunds need a human approval from a manager; agents may only propose them. Customer name, email and addresses are personal data and are never public. Needs the Products package. ## Models ### `ecommerce_category` A group of listings shown together in the shop Read group: `ecommerce.user`. Write group: `ecommerce.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `ecommerce.user` | in clear | Category name shown in the shop | | `slug` | text | `none` | no | `ecommerce.user` | in clear | URL part of the category, lowercase with dashes | | `description` | text | `none` | no | `ecommerce.user` | in clear | Short text about the category | | `sequence` | number | `none` | no | `ecommerce.user` | in clear | Order among categories, lowest first | | `published` | bool | `none` | no | `ecommerce.user` | in clear | Whether the category is visible to shoppers | ### `ecommerce_listing` A product as it appears in the online shop Read group: `ecommerce.user`. Write group: `ecommerce.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `product_id` | ref → product | `none` | no | `ecommerce.user` | in clear | The product being sold online | | `title` | text | `none` | no | `ecommerce.user` | in clear | Title shown in the shop | | `slug` | text | `none` | no | `ecommerce.user` | in clear | URL part of the product page, lowercase with dashes | | `summary` | text | `none` | no | `ecommerce.user` | in clear | One or two sentences shown on product cards | | `description` | longtext | `none` | no | `ecommerce.user` | in clear | Description shown on the product page, in Markdown | | `category_id` | ref → ecommerce_category | `none` | no | `ecommerce.user` | in clear | The category the listing is shown under | | `price` | money | `none` | no | `ecommerce.user` | in clear | Shop price including tax (minor currency units, e.g. cents) | | `compare_at_price` | money | `none` | no | `ecommerce.user` | in clear | Previous price shown crossed out (minor currency units) | | `currency` | text | `none` | no | `ecommerce.user` | in clear | ISO 4217 currency code of the prices, for example EUR; empty means the shop currency from the instance settings | | `sku` | text | `none` | no | `ecommerce.user` | in clear | Stock keeping unit shown on the product page | | `stock_policy` | select (always_available, block_when_out, allow_backorder) | `none` | no | `ecommerce.user` | in clear | What happens when stock runs out: always_available, block_when_out, allow_backorder | | `stock_quantity` | number | `none` | no | `ecommerce.user` | in clear | Units on hand, used with the stock policy. Orders do not reduce it: adjust it by hand | | `published` | bool | `none` | no | `ecommerce.user` | in clear | Whether the listing is visible to shoppers | ### `ecommerce_listing_translation` A listing translated into another language Read group: `ecommerce.user`. Write group: `ecommerce.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `listing_id` | ref → ecommerce_listing | `none` | no | `ecommerce.user` | in clear | The listing this translates | | `lang` | select (en, de, fr, pl, fi, sv, es) | `none` | no | `ecommerce.user` | in clear | Language of the translation: en, de, fr, pl, fi, sv or es | | `title` | text | `none` | no | `ecommerce.user` | in clear | Translated title | | `summary` | text | `none` | no | `ecommerce.user` | in clear | Translated card text | | `description` | longtext | `none` | no | `ecommerce.user` | in clear | Translated description in Markdown | | `published` | bool | `none` | no | `ecommerce.user` | in clear | Whether the translation is shown; it is only reachable while its listing is published | ### `ecommerce_shipping_method` A way of delivering orders with its price Read group: `ecommerce.user`. Write group: `ecommerce.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `ecommerce.user` | in clear | Name shown at checkout, for example Standard delivery | | `carrier` | text | `none` | no | `ecommerce.user` | in clear | Carrier that delivers, for example DHL | | `price` | money | `none` | no | `ecommerce.user` | in clear | Delivery price (minor currency units) | | `free_over` | money | `none` | no | `ecommerce.user` | in clear | Order total above which delivery is free (minor currency units); empty means never | | `delivery_days` | number | `none` | no | `ecommerce.user` | in clear | Usual number of days until delivery | | `active` | bool | `none` | no | `ecommerce.user` | in clear | Whether shoppers can choose it | ### `ecommerce_discount_code` A code a shopper enters at checkout for a reduction Read group: `ecommerce.user`. Write group: `ecommerce.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `code` | text | `none` | no | `ecommerce.user` | in clear | The code shoppers type, for example SPRING10 | | `kind` | select (percent, fixed) | `none` | no | `ecommerce.user` | in clear | Type of reduction: percent or fixed | | `value` | number | `none` | no | `ecommerce.user` | in clear | Reduction: basis points for percent (1000 is 10.00 percent), minor currency units for fixed | | `starts_on` | date | `none` | no | `ecommerce.user` | in clear | First day the code works (YYYY-MM-DD) | | `ends_on` | date | `none` | no | `ecommerce.user` | in clear | Last day the code works (YYYY-MM-DD) | | `max_uses` | number | `none` | no | `ecommerce.user` | in clear | How many times the code may be used in total; empty means unlimited | | `used_count` | number | `none` | no | `ecommerce.user` | in clear | How many times it has been used (kept up to date by hand) | | `minimum_order` | money | `none` | no | `ecommerce.user` | in clear | Smallest order total the code applies to (minor currency units) | | `active` | bool | `none` | no | `ecommerce.user` | in clear | Whether the code can be used | ### `ecommerce_order` An order placed online Read group: `ecommerce.user`. Write group: `ecommerce.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `number` | text | `none` | no | `ecommerce.user` | in clear | Order number shown to the shopper. Free text: packages cannot generate sequences; the public checkout makes a random one | | `customer_email` | text | `internal` | yes | `ecommerce.user` | masked | Email address of the shopper | | `customer_name` | text | `internal` | yes | `ecommerce.user` | masked | Name of the shopper | | `customer_id` | ref → party | `none` | no | `ecommerce.user` | in clear | The matching contact, once the shopper is known | | `shipping_address` | longtext | `internal` | yes | `ecommerce.user` | masked | Delivery address | | `billing_address` | longtext | `internal` | yes | `ecommerce.user` | masked | Billing address when different from delivery | | `shipping_method_id` | ref → ecommerce_shipping_method | `none` | no | `ecommerce.user` | in clear | The chosen way of delivery | | `discount_code_id` | ref → ecommerce_discount_code | `none` | no | `ecommerce.user` | in clear | The discount code used, if any | | `placed_on` | date | `none` | no | `ecommerce.user` | in clear | Day the order was placed (YYYY-MM-DD) | | `currency` | text | `none` | no | `ecommerce.user` | in clear | ISO 4217 currency code, for example EUR | | `subtotal` | money | `none` | no | `ecommerce.user` | in clear | Sum of the lines (minor currency units, entered not computed) | | `discount_total` | money | `none` | no | `ecommerce.user` | in clear | Reduction from the discount code (minor currency units) | | `shipping_total` | money | `none` | no | `ecommerce.user` | in clear | Delivery charge (minor currency units) | | `tax_total` | money | `none` | no | `ecommerce.user` | in clear | Tax included in the total (minor currency units) | | `total` | money | `none` | no | `ecommerce.user` | in clear | Amount the shopper pays (minor currency units) | | `payment_reference` | text | `financial` | yes | `ecommerce.user` | withheld | Reference from the payment provider; never a card number | | `tracking_reference` | text | `none` | no | `ecommerce.user` | in clear | Carrier tracking number | | `access_token` | text | `secret` | no | `ecommerce.user` | withheld | Secret link token that lets the shopper who placed a public order see its confirmation page. Nobody can read it back | | `state` | select (pending, paid, shipped, delivered, cancelled, refunded) | `none` | no | `ecommerce.user` | in clear | Fulfilment status: pending, paid, shipped, delivered, cancelled, refunded | ### `ecommerce_order_line` One product line of a web order Read group: `ecommerce.user`. Write group: `ecommerce.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `order_id` | ref → ecommerce_order | `none` | no | `ecommerce.user` | in clear | The order the line belongs to | | `product_id` | ref → product | `none` | no | `ecommerce.user` | in clear | The product ordered | | `listing_id` | ref → ecommerce_listing | `none` | no | `ecommerce.user` | in clear | The shop listing the line was ordered from | | `description` | text | `none` | no | `ecommerce.user` | in clear | Product text as it was when ordered | | `quantity` | number | `none` | no | `ecommerce.user` | in clear | Units ordered | | `unit_price` | money | `none` | no | `ecommerce.user` | in clear | Price per unit including tax (minor currency units) | | `subtotal` | money | `none` | no | `ecommerce.user` | in clear | Line total (minor currency units, entered not computed) | ## Workflows ### `ecommerce_order` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `ecommerce_order.mark_paid` | state: pending → paid | `ecommerce.user` | no | | `ecommerce_order.ship` | state: paid → shipped | `ecommerce.user` | no | | `ecommerce_order.deliver` | state: shipped → delivered | `ecommerce.user` | no | | `ecommerce_order.cancel` | state: pending → cancelled | `ecommerce.user` | no | | `ecommerce_order.refund` | state: paid → refunded | `ecommerce.manager` | yes | | `ecommerce_order.refund_delivered` | state: delivered → refunded | `ecommerce.manager` | yes | ## Permission groups Groups used by this app: - `ecommerce.manager` - `ecommerce.user` ## Approval steps Steps an agent can only propose: - `ecommerce_order.refund` - `ecommerce_order.refund_delivered` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `ecommerce_order.refund`, `ecommerce_order.refund_delivered`, `ecommerce_category.delete`, `ecommerce_listing.delete`, `ecommerce_listing_translation.delete`, `ecommerce_shipping_method.delete`, `ecommerce_discount_code.delete`, `ecommerce_order.delete`, `ecommerce_order_line.delete`. Personal data is masked as placeholder tokens: `ecommerce_order.customer_email`, `ecommerce_order.customer_name`, `ecommerce_order.shipping_address`, `ecommerce_order.billing_address`. These fields are withheld from agents entirely: `ecommerce_order.payment_reference`, `ecommerce_order.access_token`. ## Dependencies - `base` - `product` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from ecommerce ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/ecommerce/ --- # Website ## What it is for Hold the content of a public website: pages, menus, blog posts and the form submissions visitors send. This app stores and organises content; something else renders it. Publishing needs approval. ## What the package does and does not do Content for a public website: pages with a slug and a draft, published, archived flow, navigation menu items, blog posts with a byline and a publish date, translations of both, and the form submissions visitors send in. When the instance runs with the public site enabled it serves the published content to visitors as a server-rendered website with SEO and no login; only the fields marked public are ever visible to them. Publishing makes content public and needs a human approval, agents may only propose it. Senders are personal data and are never public. ## Models ### `website_page` A page of the website Read group: `website.user`. Write group: `website.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `website.user` | in clear | Page title | | `slug` | text | `none` | no | `website.user` | in clear | URL path of the page without slashes at the ends, lowercase with dashes, for example about-us. The page with slug home is the front page | | `body` | longtext | `none` | no | `website.user` | in clear | Page content in Markdown (a safe subset: headings, lists, links, emphasis, code, quotes, tables) | | `seo_title` | text | `none` | no | `website.user` | in clear | Title shown in search results; empty means the page title | | `seo_description` | text | `none` | no | `website.user` | in clear | Description shown in search results | | `published_on` | date | `none` | no | `website.user` | in clear | Day the page went public (YYYY-MM-DD) | | `state` | select (draft, published, archived) | `none` | no | `website.user` | in clear | Page status: draft, published, archived | ### `website_menu_item` An entry of the site navigation Read group: `website.user`. Write group: `website.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `label` | text | `none` | no | `website.user` | in clear | Text shown in the menu | | `url` | text | `none` | no | `website.user` | in clear | Where the entry leads: a page slug starting with a slash or a full address | | `parent_id` | ref → website_menu_item | `none` | no | `website.user` | in clear | The entry this one is nested under; empty for a top-level entry | | `sequence` | number | `none` | no | `website.user` | in clear | Order among its siblings, lowest first | | `visible` | bool | `none` | no | `website.user` | in clear | Whether the entry is shown | | `new_window` | bool | `none` | no | `website.user` | in clear | Whether the link opens in a new window | ### `website_blog_post` A blog post Read group: `website.user`. Write group: `website.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `website.user` | in clear | Post title | | `slug` | text | `none` | no | `website.user` | in clear | URL part of the post, lowercase with dashes | | `author_name` | text | `public_directory` | yes | `website.user` | masked | Name of the author, for the team's own records | | `byline` | text | `none` | no | `website.user` | in clear | Author line shown publicly, for example the team or brand name; use a name here only if the person agreed to it | | `summary` | text | `none` | no | `website.user` | in clear | Teaser shown in listings | | `body` | longtext | `none` | no | `website.user` | in clear | Post content in Markdown (a safe subset: headings, lists, links, emphasis, code, quotes, tables) | | `tags` | text | `none` | no | `website.user` | in clear | Comma-separated tags, for example news,product | | `published_on` | date | `none` | no | `website.user` | in clear | Day the post is dated (YYYY-MM-DD) | | `state` | select (draft, published, archived) | `none` | no | `website.user` | in clear | Post status: draft, published, archived | ### `website_form_submission` A message a visitor sent through a form on the site Read group: `website.user`. Write group: `website.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `form_name` | text | `none` | no | `website.user` | in clear | Which form it came from, for example contact | | `sender_name` | text | `internal` | yes | `website.user` | masked | Name the visitor gave | | `sender_email` | text | `internal` | yes | `website.user` | masked | Email address the visitor gave | | `sender_phone` | text | `internal` | yes | `website.user` | masked | Phone number the visitor gave | | `message` | longtext | `internal` | yes | `website.user` | masked | What the visitor wrote | | `source_page` | text | `none` | no | `website.user` | in clear | Slug of the page the form was on | | `submitted_on` | date | `none` | no | `website.user` | in clear | Day of the submission (YYYY-MM-DD) | | `consent_given` | bool | `none` | no | `website.user` | in clear | Whether the visitor ticked the consent box for being contacted | | `state` | select (new, handled, spam) | `none` | no | `website.user` | in clear | Handling status: new, handled, spam | ### `website_page_translation` A page translated into another language; the public site shows it to visitors who read that language Read group: `website.user`. Write group: `website.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `page_id` | ref → website_page | `none` | no | `website.user` | in clear | The page this translates | | `lang` | select (en, de, fr, pl, fi, sv, es) | `none` | no | `website.user` | in clear | Language of the translation: en, de, fr, pl, fi, sv or es | | `title` | text | `none` | no | `website.user` | in clear | Translated page title | | `body` | longtext | `none` | no | `website.user` | in clear | Translated page content in Markdown | | `seo_title` | text | `none` | no | `website.user` | in clear | Translated title shown in search results | | `seo_description` | text | `none` | no | `website.user` | in clear | Translated description shown in search results | | `published` | bool | `none` | no | `website.user` | in clear | Whether the translation is shown; it is only reachable while its page is published | ### `website_blog_post_translation` A blog post translated into another language Read group: `website.user`. Write group: `website.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `post_id` | ref → website_blog_post | `none` | no | `website.user` | in clear | The post this translates | | `lang` | select (en, de, fr, pl, fi, sv, es) | `none` | no | `website.user` | in clear | Language of the translation: en, de, fr, pl, fi, sv or es | | `title` | text | `none` | no | `website.user` | in clear | Translated post title | | `summary` | text | `none` | no | `website.user` | in clear | Translated teaser | | `body` | longtext | `none` | no | `website.user` | in clear | Translated post content in Markdown | | `published` | bool | `none` | no | `website.user` | in clear | Whether the translation is shown; it is only reachable while its post is published | ## Workflows ### `website_page` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `website_page.publish` | state: draft → published | `website.manager` | yes | | `website_page.unpublish` | state: published → draft | `website.manager` | no | | `website_page.archive` | state: published → archived | `website.manager` | no | ### `website_blog_post` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `website_blog_post.publish` | state: draft → published | `website.manager` | yes | | `website_blog_post.unpublish` | state: published → draft | `website.manager` | no | | `website_blog_post.archive` | state: published → archived | `website.manager` | no | ### `website_form_submission` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `website_form_submission.handle` | state: new → handled | `website.user` | no | | `website_form_submission.mark_spam` | state: new → spam | `website.user` | no | ## Permission groups Groups used by this app: - `website.manager` - `website.user` ## Approval steps Steps an agent can only propose: - `website_page.publish` - `website_blog_post.publish` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `website_page.publish`, `website_blog_post.publish`, `website_page.delete`, `website_menu_item.delete`, `website_blog_post.delete`, `website_form_submission.delete`, `website_page_translation.delete`, `website_blog_post_translation.delete`. Personal data is masked as placeholder tokens: `website_blog_post.author_name`, `website_form_submission.sender_name`, `website_form_submission.sender_email`, `website_form_submission.sender_phone`, `website_form_submission.message`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from website ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/website/ --- # Email Marketing ## What it is for Keep mailing lists with each contact's consent status and date, and prepare campaigns. Sending reaches real people and cannot be undone, so it always needs a human approval. ## What the package does and does not do Mailing lists, the contacts on them with their consent status and date, and campaigns with a draft, scheduled, sent flow. Sending is irreversible and reaches real people, so it needs a human approval from a manager; agents may only propose it. Campaign statistics are numbers entered or imported, not computed. Contacts are personal data: keep consent_status and consent_date accurate and never mail a contact that is not opted_in. ## Models ### `email_marketing_list` A mailing list contacts can belong to Read group: `email_marketing.user`. Write group: `email_marketing.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `email_marketing.user` | in clear | List name, for example Newsletter | | `description` | longtext | `none` | no | `email_marketing.user` | in clear | What subscribers receive | | `is_public` | bool | `none` | no | `email_marketing.user` | in clear | Whether visitors can subscribe themselves | ### `email_marketing_contact` A person on a mailing list Read group: `email_marketing.user`. Write group: `email_marketing.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `list_id` | ref → email_marketing_list | `none` | no | `email_marketing.user` | in clear | The list the contact is on | | `email` | text | `internal` | yes | `email_marketing.user` | masked | Email address | | `name` | text | `internal` | yes | `email_marketing.user` | masked | Name of the contact | | `consent_status` | select (pending, opted_in, opted_out, bounced) | `internal` | yes | `email_marketing.user` | masked | Permission to mail: pending, opted_in, opted_out, bounced | | `consent_date` | date | `internal` | yes | `email_marketing.user` | masked | Day the contact gave or withdrew permission (YYYY-MM-DD) | | `consent_source` | text | `internal` | yes | `email_marketing.user` | masked | Where consent was collected, for example website signup form | ### `email_marketing_campaign` One mailing sent to a list Read group: `email_marketing.user`. Write group: `email_marketing.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `email_marketing.user` | in clear | Internal campaign name | | `subject` | text | `none` | no | `email_marketing.user` | in clear | Email subject line | | `preview_text` | text | `none` | no | `email_marketing.user` | in clear | Short text shown after the subject in inboxes | | `from_name` | text | `none` | no | `email_marketing.user` | in clear | Sender name shown to recipients | | `from_email` | text | `none` | no | `email_marketing.user` | in clear | Sender address shown to recipients | | `list_id` | ref → email_marketing_list | `none` | no | `email_marketing.user` | in clear | The list the campaign goes to | | `body` | longtext | `none` | no | `email_marketing.user` | in clear | Email content (HTML or Markdown, as the sender expects) | | `scheduled_for` | date | `none` | no | `email_marketing.user` | in clear | Day the campaign is to be sent (YYYY-MM-DD) | | `sent_on` | date | `none` | no | `email_marketing.user` | in clear | Day the campaign was sent (YYYY-MM-DD) | | `sent_count` | number | `none` | no | `email_marketing.user` | in clear | Emails handed to the mail server | | `delivered_count` | number | `none` | no | `email_marketing.user` | in clear | Emails accepted by recipients' servers | | `opened_count` | number | `none` | no | `email_marketing.user` | in clear | Recipients who opened the email | | `clicked_count` | number | `none` | no | `email_marketing.user` | in clear | Recipients who clicked a link | | `bounced_count` | number | `none` | no | `email_marketing.user` | in clear | Emails that bounced | | `unsubscribed_count` | number | `none` | no | `email_marketing.user` | in clear | Recipients who unsubscribed because of this campaign | | `state` | select (draft, scheduled, sent, cancelled) | `none` | no | `email_marketing.user` | in clear | Campaign status: draft, scheduled, sent, cancelled | ## Workflows ### `email_marketing_campaign` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `email_marketing_campaign.schedule` | state: draft → scheduled | `email_marketing.user` | no | | `email_marketing_campaign.send` | state: scheduled → sent | `email_marketing.manager` | yes | | `email_marketing_campaign.send_now` | state: draft → sent | `email_marketing.manager` | yes | | `email_marketing_campaign.unschedule` | state: scheduled → draft | `email_marketing.user` | no | | `email_marketing_campaign.cancel` | state: scheduled → cancelled | `email_marketing.user` | no | ## Permission groups Groups used by this app: - `email_marketing.manager` - `email_marketing.user` ## Approval steps Steps an agent can only propose: - `email_marketing_campaign.send` - `email_marketing_campaign.send_now` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `email_marketing_campaign.send`, `email_marketing_campaign.send_now`, `email_marketing_list.delete`, `email_marketing_contact.delete`, `email_marketing_campaign.delete`. Personal data is masked as placeholder tokens: `email_marketing_contact.email`, `email_marketing_contact.name`, `email_marketing_contact.consent_status`, `email_marketing_contact.consent_date`, `email_marketing_contact.consent_source`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from email_marketing ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/email-marketing/ --- # Documents ## What it is for A register of business documents with folder, kind, owner, expiry and confidentiality. It stores references to documents, not the file contents. ## What the package does and does not do A register of business documents: folders, document records with kind, owner, expiry and confidentiality, and a draft, published and archived flow. It stores references, not file contents. ## Models ### `documents_folder` A folder that groups documents Read group: `documents.user`. Write group: `documents.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `documents.user` | in clear | Folder name | | `parent_id` | ref → documents_folder | `none` | no | `documents.user` | in clear | The folder this one sits in | | `description` | longtext | `none` | no | `documents.user` | in clear | What belongs in the folder | ### `documents_document` A contract, policy, certificate or other document on record Read group: `documents.user`. Write group: `documents.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `documents.user` | in clear | Document title | | `folder_id` | ref → documents_folder | `none` | no | `documents.user` | in clear | The folder the document is filed in | | `kind` | select (contract, invoice, policy, report, certificate, other) | `none` | no | `documents.user` | in clear | Type of document: contract, invoice, policy, report, certificate, other | | `confidentiality` | select (public, internal, confidential, restricted) | `none` | no | `documents.user` | in clear | Who should see it, as a label for readers: public, internal, confidential, restricted | | `owner` | text | `internal` | yes | `documents.user` | masked | Name of the person responsible for the document | | `reference` | text | `none` | no | `documents.user` | in clear | Reference number, for example a contract number | | `location` | text | `none` | no | `documents.user` | in clear | Where the file is stored, such as a link or archive box | | `expires_on` | date | `none` | no | `documents.user` | in clear | Date after which the document is no longer valid (YYYY-MM-DD) | | `summary` | longtext | `none` | no | `documents.user` | in clear | What the document says, in a few lines | | `state` | select (draft, published, archived) | `none` | no | `documents.user` | in clear | Document status: draft, published, archived | ## Workflows ### `documents_document` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `documents_document.publish` | state: draft → published | `documents.user` | no | | `documents_document.archive` | state: published → archived | `documents.manager` | no | ## Permission groups Groups used by this app: - `documents.manager` - `documents.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `documents_folder.delete`, `documents_document.delete`. Personal data is masked as placeholder tokens: `documents_document.owner`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from documents ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/documents/ --- # Sign ## What it is for Manage templates and signature requests through draft, sent, signed, declined and expired, and record the document hash that was signed. The platform cannot yet lock a record, so a signed request is immutable by convention only. ## What the package does and does not do Document templates and signature requests with a draft, sent, signed, declined, expired flow. A request names the signer (personal data) and keeps the signature evidence as references. A signed request is immutable by convention only: the platform cannot yet lock a record, so nobody should edit a request once it is signed, and the document_hash records what was signed. Sending and recording a signature need a human approval; agents may only propose them. ## Models ### `sign_template` A document that can be sent out for signature Read group: `sign.user`. Write group: `sign.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `sign.user` | in clear | Template name, for example Employment contract | | `description` | longtext | `none` | no | `sign.user` | in clear | What the document is for and who signs it | | `body` | longtext | `none` | no | `sign.user` | in clear | The document text, or a reference to where the file is kept | | `signer_role` | text | `none` | no | `sign.user` | in clear | Role of the person who signs, for example Customer | | `active` | bool | `none` | no | `sign.user` | in clear | Whether new requests can use the template | ### `sign_request` A request for one person to sign one document Read group: `sign.user`. Write group: `sign.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `template_id` | ref → sign_template | `none` | no | `sign.user` | in clear | The document to sign | | `title` | text | `none` | no | `sign.user` | in clear | Subject shown to the signer | | `message` | longtext | `none` | no | `sign.user` | in clear | Message shown to the signer in the request | | `signer_name` | text | `internal` | yes | `sign.user` | masked | Name of the person who must sign | | `signer_email` | text | `internal` | yes | `sign.user` | masked | Email address the request is sent to | | `sent_on` | date | `none` | no | `sign.user` | in clear | Day the request was sent (YYYY-MM-DD) | | `expires_on` | date | `none` | no | `sign.user` | in clear | Last day the request can be signed (YYYY-MM-DD) | | `signed_on` | date | `none` | no | `sign.user` | in clear | Day the document was signed (YYYY-MM-DD) | | `signer_ip` | text | `internal` | yes | `sign.manager` | masked | IP address the signature came from (evidence) | | `signer_user_agent` | text | `internal` | yes | `sign.manager` | masked | Browser the signature was made with (evidence) | | `signature_reference` | text | `none` | no | `sign.manager` | in clear | Where the signature image or certificate is stored | | `document_hash` | text | `none` | no | `sign.manager` | in clear | SHA-256 hash (hex) of the signed document, to prove it was not changed afterwards | | `state` | select (draft, sent, signed, declined, expired) | `none` | no | `sign.user` | in clear | Request status: draft, sent, signed, declined, expired | ## Workflows ### `sign_request` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `sign_request.send` | state: draft → sent | `sign.user` | yes | | `sign_request.sign` | state: sent → signed | `sign.manager` | yes | | `sign_request.decline` | state: sent → declined | `sign.user` | no | | `sign_request.expire` | state: sent → expired | `sign.user` | no | ## Permission groups Groups used by this app: - `sign.manager` - `sign.user` ## Approval steps Steps an agent can only propose: - `sign_request.send` - `sign_request.sign` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `sign_request.send`, `sign_request.sign`, `sign_template.delete`, `sign_request.delete`. Personal data is masked as placeholder tokens: `sign_request.signer_name`, `sign_request.signer_email`, `sign_request.signer_ip`, `sign_request.signer_user_agent`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from sign ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/sign/ --- # Subscriptions ## What it is for Define recurring plans and track customer subscriptions with trial, active, past due and cancelled states, including the next invoice date. It does not create invoices by itself. ## What the package does and does not do Recurring plans and customer subscriptions with a trial, active, past due and cancelled flow. It records the next invoice date; it does not create invoices on its own. ## Models ### `subscriptions_plan` A recurring offer customers can subscribe to Read group: `subscriptions.user`. Write group: `subscriptions.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `subscriptions.user` | in clear | Plan name, for example Team yearly | | `code` | text | `none` | no | `subscriptions.user` | in clear | Short code of the plan | | `interval` | select (monthly, quarterly, yearly) | `none` | no | `subscriptions.user` | in clear | How often the customer is billed: monthly, quarterly, yearly | | `price` | money | `none` | no | `subscriptions.user` | in clear | Standard price per billing interval (minor currency units, e.g. cents) | | `trial_days` | number | `none` | no | `subscriptions.user` | in clear | Length of the free trial in days | | `active` | bool | `none` | no | `subscriptions.user` | in clear | Whether new customers can pick the plan | ### `subscriptions_subscription` A customer's recurring subscription to a plan Read group: `subscriptions.user`. Write group: `subscriptions.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `customer_id` | ref → party | `none` | no | `subscriptions.user` | in clear | The customer who subscribed | | `plan_id` | ref → subscriptions_plan | `none` | no | `subscriptions.user` | in clear | The plan subscribed to | | `started_on` | date | `none` | no | `subscriptions.user` | in clear | Date the subscription began (YYYY-MM-DD) | | `trial_ends_on` | date | `none` | no | `subscriptions.user` | in clear | Date the free trial ends (YYYY-MM-DD) | | `next_invoice_on` | date | `none` | no | `subscriptions.user` | in clear | Date the next invoice is due (YYYY-MM-DD) | | `recurring_amount` | money | `none` | no | `subscriptions.user` | in clear | Amount billed each interval after discounts (minor currency units, e.g. cents) | | `currency` | text | `none` | no | `subscriptions.user` | in clear | ISO 4217 currency code, for example EUR | | `seats` | number | `none` | no | `subscriptions.user` | in clear | Number of licences or users covered | | `cancel_reason` | longtext | `none` | no | `subscriptions.user` | in clear | Why the customer cancelled | | `state` | select (trial, active, past_due, cancelled) | `none` | no | `subscriptions.user` | in clear | Subscription status: trial, active, past_due, cancelled | ## Workflows ### `subscriptions_subscription` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `subscriptions_subscription.activate` | state: trial → active | `subscriptions.manager` | no | | `subscriptions_subscription.mark_past_due` | state: active → past_due | `subscriptions.manager` | no | | `subscriptions_subscription.recover` | state: past_due → active | `subscriptions.manager` | no | | `subscriptions_subscription.cancel_trial` | state: trial → cancelled | `subscriptions.manager` | yes | | `subscriptions_subscription.cancel` | state: active → cancelled | `subscriptions.manager` | yes | | `subscriptions_subscription.cancel_overdue` | state: past_due → cancelled | `subscriptions.manager` | yes | ## Permission groups Groups used by this app: - `subscriptions.manager` - `subscriptions.user` ## Approval steps Steps an agent can only propose: - `subscriptions_subscription.cancel_trial` - `subscriptions_subscription.cancel` - `subscriptions_subscription.cancel_overdue` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `subscriptions_subscription.cancel_trial`, `subscriptions_subscription.cancel`, `subscriptions_subscription.cancel_overdue`, `subscriptions_plan.delete`, `subscriptions_subscription.delete`. ## Dependencies - `base` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from subscriptions ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/subscriptions/ --- # Dashboards ## What it is for Store dashboards and widgets as data (KPI, bar, line, pie and table definitions) for a client to render. The package never runs a query and does not check that the models it names exist. ## What the package does and does not do Dashboards and their widgets stored as data, for a UI to render. A dashboard has a title and an owner. A widget describes one tile with these fields: kind (kpi, bar, line, pie or table), model_name (the model to read, for example sale_order), measure_field (the field to aggregate; empty for count), aggregate (count or sum; sum needs a number or money measure_field), group_by (field to split by, for bar, line, pie and table; empty for a single kpi value), filter (a JSON object of field names to exact values, for example {"state":"paid"}; empty means all records) and position (order on the dashboard, lowest first). The package only stores the definition; it does not check that the model or fields exist and it computes nothing. ## Models ### `dashboards_dashboard` A dashboard: a titled, ordered collection of widgets Read group: `dashboards.user`. Write group: `dashboards.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `dashboards.user` | in clear | Dashboard title, for example Sales overview | | `owner` | text | `internal` | yes | `dashboards.user` | masked | Name of the person who owns the dashboard | | `description` | longtext | `none` | no | `dashboards.user` | in clear | What the dashboard shows and for whom | | `shared` | bool | `none` | no | `dashboards.user` | in clear | Whether everyone with access to the Dashboards app sees it, or only the owner | | `position` | number | `none` | no | `dashboards.user` | in clear | Order among dashboards, lowest first | ### `dashboards_widget` One tile on a dashboard, defined by what to read and how to aggregate it Read group: `dashboards.user`. Write group: `dashboards.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `dashboard_id` | ref → dashboards_dashboard | `none` | no | `dashboards.user` | in clear | The dashboard the widget is on | | `title` | text | `none` | no | `dashboards.user` | in clear | Tile title, for example Paid orders | | `kind` | select (kpi, bar, line, pie, table) | `none` | no | `dashboards.user` | in clear | How the tile is drawn: kpi, bar, line, pie, table | | `model_name` | text | `none` | no | `dashboards.user` | in clear | Model the tile reads, for example sale_order | | `measure_field` | text | `none` | no | `dashboards.user` | in clear | Field to aggregate: a number or money field of the model; empty when counting | | `aggregate` | select (count, sum) | `none` | no | `dashboards.user` | in clear | How records are combined: count or sum | | `group_by` | text | `none` | no | `dashboards.user` | in clear | Field to split the result by (a select, ref or date field); empty for one value | | `filter` | longtext | `none` | no | `dashboards.user` | in clear | JSON object of field names to exact values that records must match, for example {"state":"paid"}; empty means all records | | `position` | number | `none` | no | `dashboards.user` | in clear | Order of the tile on the dashboard, lowest first | ## Workflows This app has no workflow actions; records are created and edited directly. ## Permission groups Groups used by this app: - `dashboards.manager` - `dashboards.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `dashboards_dashboard.delete`, `dashboards_widget.delete`. Personal data is masked as placeholder tokens: `dashboards_dashboard.owner`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from dashboards ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/dashboards/ --- # Accounting ## What it is for A chart of accounts, journals, tax rates, journal entries with debit and credit lines, fiscal periods and bank statement lines. Nothing is computed for you: balancing and tax amounts are the responsibility of whoever enters them. Posting and closing need approval. ## What the package does and does not do Chart of accounts, journals, tax rates, journal entries with debit and credit lines, fiscal years and periods, and bank statement lines. Amounts are money fields in minor currency units and tax rates are numbers in basis points (2000 means 20.00 percent). Nothing is computed: balancing of debits and credits, tax amounts and entry numbers are the responsibility of whoever enters them. Posting an entry and closing a period or fiscal year need a human approval and the stricter accounting.approver group; agents may only propose them. ## Models ### `accounting_account` An account of the chart of accounts Read group: `accounting.user`. Write group: `accounting.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `code` | text | `none` | no | `accounting.user` | in clear | Account code as used in the chart, for example 400000 | | `name` | text | `none` | no | `accounting.user` | in clear | Account name, for example Trade receivables | | `account_type` | select (asset_receivable, asset_cash, asset_current, asset_fixed, liability_payable, liability_current, liability_non_current, equity, income, income_other, expense, expense_direct_cost) | `none` | no | `accounting.user` | in clear | Type of account: asset_receivable, asset_cash, asset_current, asset_fixed, liability_payable, liability_current, liability_non_current, equity, income, income_other, expense, expense_direct_cost | | `reconcile` | bool | `none` | no | `accounting.user` | in clear | Whether open items on this account can be reconciled (receivable, payable, bank clearing) | | `deprecated` | bool | `none` | no | `accounting.user` | in clear | Whether the account is closed to new postings | | `note` | longtext | `none` | no | `accounting.user` | in clear | Internal remarks about how the account is used | ### `accounting_journal` A journal that groups entries of one kind (sales, purchases, bank, cash, miscellaneous) Read group: `accounting.user`. Write group: `accounting.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `accounting.user` | in clear | Journal name, for example Customer invoices | | `code` | text | `none` | no | `accounting.user` | in clear | Short code used in entry numbers, for example INV | | `kind` | select (sale, purchase, cash, bank, general) | `none` | no | `accounting.user` | in clear | What the journal records: sale, purchase, cash, bank, general | | `default_account_id` | ref → accounting_account | `none` | no | `accounting.user` | in clear | Default account for entries of this journal (the bank account for a bank journal) | | `currency` | text | `none` | no | `accounting.user` | in clear | ISO 4217 currency code of the journal, for example EUR; empty means the company currency | | `active` | bool | `none` | no | `accounting.user` | in clear | Whether the journal can still receive entries | ### `accounting_tax` A tax rate that entry lines can refer to Read group: `accounting.user`. Write group: `accounting.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `accounting.user` | in clear | Tax name, for example VAT 20% sales | | `rate_bp` | number | `none` | no | `accounting.user` | in clear | Rate in basis points: 2000 means 20.00 percent, 550 means 5.50 percent | | `scope` | select (sales, purchases, none) | `none` | no | `accounting.user` | in clear | Where the tax applies: sales, purchases, none | | `price_included` | bool | `none` | no | `accounting.user` | in clear | Whether prices already include this tax | | `tax_account_id` | ref → accounting_account | `none` | no | `accounting.user` | in clear | Account the tax amount is booked on | | `active` | bool | `none` | no | `accounting.user` | in clear | Whether the tax can still be chosen on new lines | ### `accounting_fiscal_year` A fiscal year, opened and closed as a whole Read group: `accounting.user`. Write group: `accounting.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `accounting.user` | in clear | Fiscal year name, for example FY2026 | | `start_on` | date | `none` | no | `accounting.user` | in clear | First day of the fiscal year (YYYY-MM-DD) | | `end_on` | date | `none` | no | `accounting.user` | in clear | Last day of the fiscal year (YYYY-MM-DD) | | `state` | select (open, closed) | `none` | no | `accounting.user` | in clear | Fiscal year status: open, closed | ### `accounting_period` An accounting period (normally a month) inside a fiscal year Read group: `accounting.user`. Write group: `accounting.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `fiscal_year_id` | ref → accounting_fiscal_year | `none` | no | `accounting.user` | in clear | The fiscal year the period belongs to | | `name` | text | `none` | no | `accounting.user` | in clear | Period name, for example 2026-03 | | `start_on` | date | `none` | no | `accounting.user` | in clear | First day of the period (YYYY-MM-DD) | | `end_on` | date | `none` | no | `accounting.user` | in clear | Last day of the period (YYYY-MM-DD) | | `state` | select (open, closed) | `none` | no | `accounting.user` | in clear | Period status: open, closed | ### `accounting_move` A journal entry: a dated set of debit and credit lines in one journal Read group: `accounting.user`. Write group: `accounting.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `journal_id` | ref → accounting_journal | `none` | no | `accounting.user` | in clear | The journal the entry belongs to | | `date` | date | `none` | no | `accounting.user` | in clear | Accounting date of the entry (YYYY-MM-DD) | | `number` | text | `none` | no | `accounting.user` | in clear | Entry number such as INV/2026/0001. Free text: packages cannot generate sequences, so whoever posts the entry assigns the next number by hand and keeps the series gapless | | `reference` | text | `none` | no | `accounting.user` | in clear | External reference, for example the supplier's invoice number | | `partner_id` | ref → party | `none` | no | `accounting.user` | in clear | The contact the entry is about | | `narration` | longtext | `none` | no | `accounting.user` | in clear | Free-text note about the entry | | `state` | select (draft, posted, cancelled, reversed) | `none` | no | `accounting.user` | in clear | Entry status: draft, posted, cancelled, reversed | ### `accounting_move_line` One debit or credit line of a journal entry Read group: `accounting.user`. Write group: `accounting.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `move_id` | ref → accounting_move | `none` | no | `accounting.user` | in clear | The journal entry the line belongs to | | `account_id` | ref → accounting_account | `none` | no | `accounting.user` | in clear | The account the line is booked on | | `partner_id` | ref → party | `none` | no | `accounting.user` | in clear | The contact the line is about | | `label` | text | `none` | no | `accounting.user` | in clear | Line description | | `debit` | money | `financial` | no | `accounting.user` | withheld | Debit amount in minor currency units; 0 when the line is a credit | | `credit` | money | `financial` | no | `accounting.user` | withheld | Credit amount in minor currency units; 0 when the line is a debit | | `tax_id` | ref → accounting_tax | `none` | no | `accounting.user` | in clear | Tax the line carries, if any | | `due_on` | date | `none` | no | `accounting.user` | in clear | Due date for receivable and payable lines (YYYY-MM-DD) | | `reconciled` | bool | `none` | no | `accounting.user` | in clear | Whether the line has been matched against a payment | ### `accounting_bank_statement_line` A line of a bank statement waiting to be matched to entries Read group: `accounting.user`. Write group: `accounting.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `journal_id` | ref → accounting_journal | `none` | no | `accounting.user` | in clear | The bank journal the statement belongs to | | `date` | date | `none` | no | `accounting.user` | in clear | Booking date at the bank (YYYY-MM-DD) | | `label` | text | `none` | no | `accounting.user` | in clear | Payment reference or description as the bank reports it | | `amount` | money | `financial` | no | `accounting.user` | withheld | Amount in minor currency units; positive for money in, negative for money out | | `counterparty_name` | text | `financial` | yes | `accounting.user` | withheld | Name of the payer or payee as the bank reports it | | `counterparty_iban` | text | `financial` | yes | `accounting.user` | withheld | Bank account of the payer or payee | | `move_line_id` | ref → accounting_move_line | `none` | no | `accounting.user` | in clear | The entry line this statement line was matched to | | `state` | select (unmatched, matched, reconciled) | `none` | no | `accounting.user` | in clear | Matching status: unmatched, matched, reconciled | ## Workflows ### `accounting_fiscal_year` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `accounting_fiscal_year.close` | state: open → closed | `accounting.approver` | yes | | `accounting_fiscal_year.reopen` | state: closed → open | `accounting.approver` | yes | ### `accounting_period` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `accounting_period.close` | state: open → closed | `accounting.approver` | yes | | `accounting_period.reopen` | state: closed → open | `accounting.approver` | yes | ### `accounting_move` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `accounting_move.post` | state: draft → posted | `accounting.approver` | yes | | `accounting_move.cancel` | state: draft → cancelled | `accounting.user` | no | | `accounting_move.reverse` | state: posted → reversed | `accounting.approver` | yes | ### `accounting_bank_statement_line` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `accounting_bank_statement_line.match` | state: unmatched → matched | `accounting.user` | no | | `accounting_bank_statement_line.reconcile` | state: matched → reconciled | `accounting.manager` | no | | `accounting_bank_statement_line.unmatch` | state: matched → unmatched | `accounting.user` | no | ## Permission groups Groups used by this app: - `accounting.approver` - `accounting.manager` - `accounting.user` ## Approval steps Steps an agent can only propose: - `accounting_fiscal_year.close` - `accounting_fiscal_year.reopen` - `accounting_period.close` - `accounting_period.reopen` - `accounting_move.post` - `accounting_move.reverse` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `accounting_fiscal_year.close`, `accounting_fiscal_year.reopen`, `accounting_period.close`, `accounting_period.reopen`, `accounting_move.post`, `accounting_move.reverse`, `accounting_account.delete`, `accounting_journal.delete`, `accounting_tax.delete`, `accounting_fiscal_year.delete`, `accounting_period.delete`, `accounting_move.delete`, `accounting_move_line.delete`, `accounting_bank_statement_line.delete`. These fields are withheld from agents entirely: `accounting_move_line.debit`, `accounting_move_line.credit`, `accounting_bank_statement_line.amount`, `accounting_bank_statement_line.counterparty_name`, `accounting_bank_statement_line.counterparty_iban`. ## Dependencies - `base` ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from accounting ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/accounting/ --- # Knowledge ## What it is for A wiki of articles arranged in a tree, with draft, published and archived states and tags. Visibility is a label: there are no row-level permissions yet, so everyone with access to the app reads every article. ## What the package does and does not do A wiki: articles arranged in a tree through a parent article, with a draft, published, archived flow, a visibility setting and tags. Visibility is a label for the UI and for people to respect; the platform has no row-level permissions yet, so everyone with the Knowledge groups can read every article. ## Models ### `knowledge_article` An article of the knowledge base Read group: `knowledge.user`. Write group: `knowledge.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `title` | text | `none` | no | `knowledge.user` | in clear | Article title | | `parent_id` | ref → knowledge_article | `none` | no | `knowledge.user` | in clear | The article this one is nested under; empty for a top-level article | | `body` | longtext | `none` | no | `knowledge.user` | in clear | Article content (Markdown) | | `visibility` | select (private, workspace, public) | `none` | no | `knowledge.user` | in clear | Who the article is meant for: private, workspace, public | | `tags` | text | `none` | no | `knowledge.user` | in clear | Comma-separated tags, for example onboarding,hr | | `author_name` | text | `public_directory` | yes | `knowledge.user` | masked | Name of the author | | `sequence` | number | `none` | no | `knowledge.user` | in clear | Order among its siblings, lowest first | | `published_on` | date | `none` | no | `knowledge.user` | in clear | Day the article was published (YYYY-MM-DD) | | `state` | select (draft, published, archived) | `none` | no | `knowledge.user` | in clear | Article status: draft, published, archived | ## Workflows ### `knowledge_article` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `knowledge_article.publish` | state: draft → published | `knowledge.manager` | no | | `knowledge_article.unpublish` | state: published → draft | `knowledge.manager` | no | | `knowledge_article.archive` | state: published → archived | `knowledge.manager` | no | ## Permission groups Groups used by this app: - `knowledge.manager` - `knowledge.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `knowledge_article.delete`. Personal data is masked as placeholder tokens: `knowledge_article.author_name`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from knowledge ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/knowledge/ --- # Appointments ## What it is for Describe bookable people, rooms and equipment and record appointments with a date, start and end. Overlaps are not prevented automatically. ## What the package does and does not do Bookable resources (people, rooms, equipment) and appointments with a date, start and end time and a status flow. Overlaps are not prevented automatically. ## Models ### `appointments_resource` Something or someone that can be booked Read group: `appointments.user`. Write group: `appointments.manager`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `name` | text | `none` | no | `appointments.user` | in clear | Name of the resource, for example Meeting room 2 | | `kind` | select (person, room, equipment) | `none` | no | `appointments.user` | in clear | What kind of resource it is: person, room, equipment | | `capacity` | number | `none` | no | `appointments.user` | in clear | How many people it can serve at once | | `active` | bool | `none` | no | `appointments.user` | in clear | Whether the resource can be booked | ### `appointments_appointment` A booking of a resource at a time Read group: `appointments.user`. Write group: `appointments.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `resource_id` | ref → appointments_resource | `none` | no | `appointments.user` | in clear | The resource booked | | `title` | text | `none` | no | `appointments.user` | in clear | What the appointment is for | | `day` | date | `none` | no | `appointments.user` | in clear | Date of the appointment (YYYY-MM-DD) | | `start_minute` | number | `none` | no | `appointments.user` | in clear | Start time as minutes since midnight, for example 540 for 09:00 | | `end_minute` | number | `none` | no | `appointments.user` | in clear | End time as minutes since midnight, for example 600 for 10:00 | | `attendee_name` | text | `internal` | yes | `appointments.user` | masked | Name of the person attending | | `attendee_email` | text | `internal` | yes | `appointments.user` | masked | Email address of the attendee | | `attendee_phone` | text | `internal` | yes | `appointments.user` | masked | Phone number of the attendee | | `notes` | longtext | `none` | no | `appointments.user` | in clear | Anything the host should know beforehand | | `state` | select (scheduled, confirmed, done, cancelled, no_show) | `none` | no | `appointments.user` | in clear | Booking status: scheduled, confirmed, done, cancelled, no_show | ## Workflows ### `appointments_appointment` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `appointments_appointment.confirm` | state: scheduled → confirmed | `appointments.user` | no | | `appointments_appointment.complete` | state: confirmed → done | `appointments.user` | no | | `appointments_appointment.cancel` | state: scheduled → cancelled | `appointments.user` | no | | `appointments_appointment.mark_no_show` | state: confirmed → no_show | `appointments.user` | no | ## Permission groups Groups used by this app: - `appointments.manager` - `appointments.user` ## Approval steps No step in this app needs an approval, apart from deleting records. ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `appointments_resource.delete`, `appointments_appointment.delete`. Personal data is masked as placeholder tokens: `appointments_appointment.attendee_name`, `appointments_appointment.attendee_email`, `appointments_appointment.attendee_phone`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from appointments ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/appointments/ --- # Expenses ## What it is for Employees file expense claims that move through draft, submitted, approved and paid. Paying needs its own permission and a human approval; receipts are referenced by note, not stored. ## What the package does and does not do Employee expense claims with a draft, submitted, approved and paid flow. Paying is a separate permission and needs a human approval; receipts are referenced by note, not stored. ## Models ### `expenses_expense` An amount an employee spent for the business and wants reimbursed Read group: `expenses.user`. Write group: `expenses.user`. | Field | Kind | Data class | Personal data | Group | Agents see | Description | |---|---|---|---|---|---|---| | `description` | text | `none` | no | `expenses.user` | in clear | What the money was spent on | | `employee` | text | `internal` | yes | `expenses.user` | masked | Name of the person claiming the expense | | `spent_on` | date | `none` | no | `expenses.user` | in clear | Date of the purchase (YYYY-MM-DD) | | `category` | select (travel, meals, lodging, supplies, software, other) | `none` | no | `expenses.user` | in clear | Expense category: travel, meals, lodging, supplies, software, other | | `amount` | money | `financial` | yes | `expenses.user` | withheld | Amount owed to the employee (minor currency units, e.g. cents) | | `currency` | text | `none` | no | `expenses.user` | in clear | ISO 4217 currency code, for example EUR | | `receipt_note` | longtext | `none` | no | `expenses.user` | in clear | Where the receipt is kept and what it shows | | `paid_on` | date | `financial` | yes | `expenses.finance` | withheld | Date the employee was reimbursed (YYYY-MM-DD) | | `payment_reference` | text | `financial` | yes | `expenses.finance` | withheld | Bank transfer reference of the reimbursement | | `state` | select (draft, submitted, approved, paid, refused) | `none` | no | `expenses.user` | in clear | Claim status: draft, submitted, approved, paid, refused | ## Workflows ### `expenses_expense` | Action | Moves | Who may run it | Approval | |---|---|---|---| | `expenses_expense.submit` | state: draft → submitted | `expenses.user` | no | | `expenses_expense.approve` | state: submitted → approved | `expenses.manager` | no | | `expenses_expense.pay` | state: approved → paid | `expenses.finance` | yes | | `expenses_expense.refuse` | state: submitted → refused | `expenses.manager` | no | ## Permission groups Groups used by this app: - `expenses.finance` - `expenses.manager` - `expenses.user` ## Approval steps Steps an agent can only propose: - `expenses_expense.pay` ## What agents can and cannot do An agent can read, search, create and update records of this app, and run the actions that need no approval, within the permission groups of its token. It can only propose these steps, and a person must approve them: `expenses_expense.pay`, `expenses_expense.delete`. Personal data is masked as placeholder tokens: `expenses_expense.employee`. These fields are withheld from agents entirely: `expenses_expense.amount`, `expenses_expense.paid_on`, `expenses_expense.payment_reference`. ## Dependencies None. ## Install Install it from the Customize app (Marketplace), or from the command line: ```bash obop modules install-from expenses ``` Needs a database and administrator rights. ## Build your own [The module guide shows how apps like this one are written](/docs/reference/module-manifest/) Source: https://obop-platform.sliplane.app/apps/expenses/ --- # Community ## The forum The community forum lives at [/community/](/community/). It is separate from this website and has its own sitemap. Use it for questions about using obop, ideas, and showing what you have built. If the forum is not reachable yet, the [GitHub repository](https://github.com/SpaceCorps/obop) is the place for bug reports and feature requests: open an issue. ## Ground rules 1. **Be kind and assume good faith.** Disagree with ideas, not people. 2. **Be specific.** Say what you did, what you expected and what happened. Include the version and how you run obop. 3. **Never post personal data or secrets.** No customer details, no agent tokens, no passwords, no database URLs. Redact logs before you paste them. 4. **Security problems are not public posts.** Report vulnerabilities privately; see [Security](/security/). 5. **No advertising or spam.** Relevant links to your own marketplace or app are welcome when you say it is yours. 6. **Stay on topic**, and use the language you are most comfortable in; English is the common one. Moderators may edit or remove posts that break these rules. ## Contribute - **Report bugs and ideas** as GitHub issues. - **Write a package.** The [module manifest reference](/docs/reference/module-manifest/) shows how, and you can publish it in [your own marketplace](/marketplace/). - **Improve the documentation.** Every docs page has an _Edit this page on GitHub_ link. - **Review a translation.** The translations of this website are machine-drafted and need native review; see the repository's site documentation. Source: https://obop-platform.sliplane.app/about/community/ --- # About ## What we are building A modular business suite, in the spirit of the big all-in-one ERP systems, that is open, portable and ready for AI agents from the start. It covers CRM, sales, invoicing, products and contacts today and aims at accounting, inventory, manufacturing, HR, projects, point of sale, e-commerce, website, marketing, helpdesk and documents. obop is a clean-room re-implementation of ideas that business software has proven over decades. It does not copy anyone's code. ## Principles - **One business, one platform.** One data model for every app, so nothing needs gluing together. - **Agents are first-class, and limited.** Scoped tokens, masked personal data, and approvals for anything irreversible. - **Open and portable.** The source is public, it runs on PostgreSQL, and a business's data is its own. - **Honest.** We say what is built and what is not. No invented customers, quotes or numbers: this site has none because there is nothing real to show yet. - **Plain language.** Documentation and interface say what they mean. ## Who builds it obop is built by [SpaceCorps](https://github.com/SpaceCorps). The code, the decisions and the roadmap are in the [repository](https://github.com/SpaceCorps/obop). ## Technology The backend is Rust. The web apps use Vite+ and React. This website is a static site generated with VitePress from Markdown and a shared design system, in seven languages. ## Where to go next - [Platform](/platform/): how it works. - [Roadmap](/roadmap/): what is built, what is not. - [Community](/about/community/) and [Status](/about/status/). - [Contact](/contact/). Source: https://obop-platform.sliplane.app/about/ --- # Status ## Live status The live status page is at [/status](/status). It is served by the control plane itself and reports the state of the service. If it does not load, that is a sign that the control plane is down. ## What the status page covers - The control plane: sign-up, sign-in and the instance list. - The hosted instances the control plane provisions. ## What it does not promise obop is in beta. There is no service level agreement, no uptime target and no support commitment yet. Instances may be restarted, upgraded by hand or lost while the platform matures; nothing backs up the hosted databases yet. Do not keep the only copy of important data in the hosted beta. ## If something is down 1. Check the [status page](/status). 2. Look at the open issues in the [repository](https://github.com/SpaceCorps/obop/issues). 3. Report it there if it is not already listed, without personal data or tokens. Source: https://obop-platform.sliplane.app/about/status/ --- # Brand ## Name The name is **obop**: lowercase always, including at the start of a sentence. Never "Obop", "OBOP" or "the obop". ## Logo The logo is a mark and a wordmark, drawn as SVG in one colour. The mark is a ring open on one side with a dot in the opening: one platform, the business in orbit. The wordmark is lowercase, geometric and monoline, drawn from the same circles, so it needs no font. | Download | Use on | | ---------------------------------------------- | ----------------------------------- | | [Full logo, ink](/brand/obop-logo.svg) | Light backgrounds | | [Full logo, white](/brand/obop-logo-white.svg) | Dark backgrounds | | [Full logo, black](/brand/obop-logo-black.svg) | One-colour print | | [Mark](/brand/obop-mark.svg) | Favicons, app badges | | [Wordmark](/brand/obop-wordmark.svg) | Tight horizontal spaces | | [Favicon](/favicon.svg) | The mark in white on an accent tile | Rules: - One colour: ink on light, near-white on dark, the on-accent colour on an accent background, or pure black for print. Never gradients, outlines or shadows. - Clear space of x on every side, where x is half the height of the logo. - Minimum size: mark 16 px; full logo 20 px high. - Do not stretch, rotate, recolour with effects, or place on low-contrast or busy backgrounds. ## Colour Colours are OKLCH ramps with the same lightness ladder in every hue. Neutral is a cool grey. The accent is chosen by the person or administrator; the default is a violet, and its ramp is generated and checked for contrast: text on the accent fill at least 4.5:1, accent text on panels 4.5:1, fills against panels 3:1. Signals are success, warning, danger and info. Never rely on colour alone. ## Typography Sans: **Geist Variable**. Mono: **Geist Mono Variable**. Both are open source (SIL OFL 1.1) and served from this site; there are no third-party font hosts. Numbers are tabular where they line up. Headings are weight 600 with tight tracking. ## Voice Plain, specific, calm, honest, helpful, human. Sentence case. No exclamation marks. No invented customers, quotes or metrics anywhere. | Do not | Do | | --------------------------- | ------------------------------------------------------------- | | Oops! Something went wrong! | Could not save the lead. Check your connection and try again. | | No data. | No leads yet. Create the first one to start your pipeline. | | Submit | Create invoice | | Action pending. | Waiting for a human to approve: post invoice INV-0042. | ## Motion and accessibility Animate transform and opacity only, and never autoplay decoration; reduced-motion settings are respected. The target is WCAG 2.2 level AA: visible focus, 24 px minimum targets, keyboard access for everything, and support for forced colours, 320 px reflow and 400 percent zoom. ## Source The brand book is `docs/brand/BRAND.md` and the design system is the `@obop/ui` package in the repository. Source: https://obop-platform.sliplane.app/brand/ --- # Changelog Each line is a commit message with its short hash. Read the [roadmap](/roadmap/) for the bigger picture. ## 2026-10-16 - Redesign the platform app and the landing page on @obop/ui; add brand and design-system docs (`7f9689d`) - Add the Brand book to Customize: foundations, iconography, components, patterns, voice, accessibility (`846e8b9`) - Migrate the backoffice onto @obop/ui: new shell, icons, tokens and primitives (`c067645`) - Document governed fields, delete and search (D48, D49, MODULES.md), CLI list options (`89506f7`) - Test governed fields, delete, search and modules check across modules, packages and REST (`9585e0c`) - Run transition actions for implicit transitions, add modules check FILE, test governed fields, delete and search (`ece95b2`) - Add @obop/ui: design tokens, OKLCH colour system with AA check, icon set, logo and primitives (`d48d691`) - Governed fields, delete, search: registry transitions, dispatcher guards and generated commands (`6c960bb`) - Add the ambient ASCII field hero effect (prototype, to be used by the main website) (`7895585`) - Write docs/MODULES.md and test the tutorial module and its documented errors (`df6424d`) - Add ten bundled apps: accounting, planning, field service, POS, eCommerce, website, email marketing, sign, dashboards, knowledge (`1ebe041`) - Provisioner: wait for the public address to resolve before reporting an instance running (`0ace171`) - Name each withheld field once per list; label the palette input; enable both regression tests (`2a50b21`) - e2e: wait for saves before switching views; action timeout (`24a96f1`) - e2e: filters, analysis, customize, permissions, agent, platform and a11y specs; README; CI job (`e6f8ee3`) - Add Playwright e2e package: fixtures, servers, auth, navigation, records and kanban specs (`67ebc65`) - Backoffice: Odoo-style control panel, five views and a richer record form (`f04a47b`) - Backoffice: module builder emits typed fields and a status shortcut (`900a7a0`) - Backoffice: typed field contract, shared data layer, client-side query engine (`071f315`) - Test every bundled package end to end through the dispatcher (`62c62a7`) - Bundle twelve declarative packages and upgrade fleet, helpdesk and projects to 0.2.0 (`5f3dcb1`) - Typed fields: kinds, validation on write, typed manifests and /api/meta (D47) (`37103b1`) - Add CI: rust (fmt, clippy, tests incl. PostgreSQL), web (vp check and build), lockfile policy (`e64eb36`) - New backoffice: registry-driven UI with floating panels and a Customize app (`c0fcbf2`) - Sliplane: wait through a brief 'service not found' after create; short CLI errors (`81f9e30`) - Instant delete and live job status (`81da737`) - Fix: run HTTP handlers on the blocking pool so PostgreSQL works inside the runtime (`d70be7f`) - Fix frozen lockfile: landing app needs an importer entry (`cd22398`) - Add the landing page; platform app moves under /app/ (`4aca328`) - docs: describe the Sliplane deployment as built (`7a17464`) - Sliplane: cell host split, no auto-deploy for instances, CLI and key entrypoint in the image (`663f1cb`) - Dockerfile for both roles, deployment doc, decision D45 (`8bc8fc6`) - Add the control plane: sign up, get your own instance (`1d67f39`) - Add declarative module packages, runtime install, marketplace and the Modules page (`a03e72d`) - Add real authentication: obop-auth, login, scoped agent tokens, sign-in screen (`519b823`) - Add obop-storage: SQLite locally, PostgreSQL for deployments (D44) (`308801b`) - Add backoffice UI over the REST surface and update CLAUDE.md status (`143caf9`) - Add REST (obop-http), MCP (obop-mcp), generated CRUD, approval flow and four modules (`b86a829`) - Add obop-commands: one dispatch path for REST, MCP and CLI (`d42116d`) - Add registry install gate, masking vault, base module and 'obop modules check' (`e13aae5`) - docs: propose D40-D43 (masking, agent-ready modules, agent safety timing, hosting) (`1d30564`) - Scaffold Cargo workspace and Vite+ web workspace (`87b7e12`) Source: https://obop-platform.sliplane.app/changelog/ --- # Contact | For | Where | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | Bug reports and feature requests | [GitHub issues](https://github.com/SpaceCorps/obop/issues) | | Questions and ideas | The [community forum](/about/community/) | | Security vulnerabilities | A private [GitHub security advisory](https://github.com/SpaceCorps/obop/security/advisories/new). See [Security](/security/). | | Privacy questions about this website or the hosted beta | Open an issue without personal data and say you have a privacy question; we will move it to a private channel. | | Press, partners, commercial questions | Open an issue titled "Commercial" with a way to reach you that you are happy to publish, or use the repository's contact details. | We will add a mailbox here when the project has one. Until then, please do not send sensitive information through public channels. ## What helps us answer quickly - The version of obop and how you run it (hosted beta, Docker, local). - What you did, what you expected and what happened. - Logs with personal data, tokens and passwords removed. Source: https://obop-platform.sliplane.app/contact/ --- # The approval loop ## Which steps need approval - Every action marked `approval = true` in its module. In the built-in apps that is `invoice.post` and `party.erase`; in bundled packages it includes posting accounting entries, refunds, sending campaigns, publishing website content, signing, and paying expenses. Each [app page](/apps/) lists them. - Every `delete`. - Installing, upgrading and removing modules (`module.install`, `module.uninstall`, `marketplace_listing.install`). `GET /api/commands` marks them with `"needs_approval": true`, and the MCP tool description ends with "(agents propose; a human approves)". ## The loop 1. **The agent calls the command.** Same call as any other. 2. **The server answers with a plan.** Nothing is changed. Over REST it is `202 Accepted` with `{"approval_required":true,"plan_id":"..."}`. Over MCP the tool result is `{"status":"approval_required","plan_id":"..."}`. The CLI prints `ApprovalRequired { plan_id: ... }`. 3. **The agent tells a person.** The plan id and a plain description of what it wanted to do and why. The agent cannot approve its own plan: an agent token asking to approve gets `404`. 4. **A person approves.** They sign in and call `POST /api/approve/{plan_id}` with their own session token. 5. **The action runs.** The server checks again that the approver holds the action's group, that the proposing agent was allowed to do it, and that the record still allows the change. The result comes back to the approver. ## Example: posting an invoice An agent with `invoicing.manager` finds a draft invoice and asks to post it. ```bash curl -si -X POST http://127.0.0.1:8080/api/invoice/post \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"id":"01a1464f-b0cb-7689-be17-2311cd62d24c"}' ``` ```text HTTP/1.1 202 Accepted {"approval_required":true,"plan_id":"01a14662-bb99-74fe-bc64-1b98f21d855f"} ``` The agent reports: _"Draft invoice 01a1464f... for 1,200.00 is ready to post. Waiting for approval: plan 01a14662-bb99..."_ A person with `invoicing.manager` approves: ```bash curl -si -X POST http://127.0.0.1:8080/api/approve/01a14662-bb99-74fe-bc64-1b98f21d855f \ -H "Authorization: Bearer $SESSION_TOKEN" ``` ```text HTTP/1.1 200 OK {"denied":[],"value":{"id":"01a1464f-b0cb-7689-be17-2311cd62d24c","number":"INV-00001","state":"posted"}} ``` The invoice is posted and has its legal number. Approving the same plan again is `404`: a plan runs once. Approving something that no longer applies, or that the approver may not run, fails with `404` or a `400` that says why. An unknown or already-used plan id is `404`. ## Over MCP ```json { "jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": { "name": "lead__delete", "arguments": { "id": "01a1464f-b0c9-7689-8fe1-ec6283086c76" } } } ``` ```json { "jsonrpc": "2.0", "id": 4, "result": { "content": [ { "type": "text", "text": "{\"plan_id\":\"01a14660-1971-733a-8a1e-19e32cc341b2\",\"status\":\"approval_required\"}" } ], "isError": false } } ``` `isError` is `false` because a proposal is not a failure. Tell the agent in its instructions to treat `approval_required` as "stop and ask a person". ## Limits, honestly - Pending plans are kept in the server's memory. A restart forgets them; the agent has to propose again. - Plans do not expire on their own. - There is no list-pending-plans call. The plan id travels from the agent to the approver by whatever channel you set up (a chat message, a ticket). - A person approving should read what the plan does. The plan id alone does not show it; ask the agent to describe the change in words before you approve. See [Safety best practices](/docs/agents/safety/). Source: https://obop-platform.sliplane.app/docs/agents/approvals/ --- # Use the CLI The CLI opens the database directly, like `obop mcp`, so it runs where the database is reachable. For a hosted instance use [REST](/docs/agents/rest/). ## Set the token and database once ```bash export OBOP_TOKEN=obop_your_agent_token export OBOP_DATABASE_URL=sqlite:///home/me/obop.db ``` Or pass `--token` and `--db` on every call. ## List what the token may do ```bash obop commands ``` ```text lead.create Create a record lead.delete Delete a record that no other record refers to [needs approval for agents] lead.get Read one record by id lead.list List records; optional filter, order, limit and offset lead.qualify Move a new lead to the qualified stage lead.search Like list, but returns {rows, total} with the count before limit and offset lead.update Update fields of a record ``` ## Call a command `obop call [key=value ...]`. Numbers, money and booleans are converted from text using the model's field types, so `expected_revenue_minor=5000` is an integer. ```bash obop call lead search limit=1 obop call lead create title="Spring campaign" expected_revenue_minor=5000 obop call lead qualify id=01a14650-c0c4-72e3-bb4d-36d1f28fa20d ``` The result is printed as indented JSON. Masking applies exactly as over REST: ```json { "rows": [ { "contact_email": "CONTACT_EMAIL_1", "contact_name": "CONTACT_NAME_2", "expected_revenue_minor": 500000, "id": "01a1464f-b0c9-7689-8fe1-ec6283086c76", "stage": "qualified", "title": "Annual support contract" } ], "total": 4 } ``` ## Approvals and errors A command that needs approval is not run. The CLI prints the plan and exits with an error: ```text error: ApprovalRequired { plan_id: "01a14650-d1b3-74c4-91b6-9cc0b09d082d" } ``` Other errors print the reason, for example `error: Handler("no lead nope")`. There is no `approve` command in the CLI: a person approves over REST or in the web app. See [The approval loop](/docs/agents/approvals/). Each `obop call` is a separate process, so masking tokens do not carry over from one call to the next. To use a token you read earlier, stay inside one MCP or REST session. ## Using it from a script ```bash #!/bin/sh set -e # The newest 50 leads that are still in the "new" stage, as JSON obop call lead search 'filter=[{"field":"stage","op":"eq","value":"new"}]' limit=50 ``` Structured arguments such as `filter` are JSON text. For anything beyond a few fields, REST with `jq` is usually easier to read than the CLI. ## Flags | Flag | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------------- | | `--token TOKEN` | Bearer token. Default: `OBOP_TOKEN`. | | `--db URL` | `sqlite://file.db`, `postgres://user:pass@host/db` or `memory:`. Default: `OBOP_DATABASE_URL`, then `sqlite://obop.db`. | | `--dev-auth` | Use the fixed dev tokens (`dev-admin`, `dev-sales`, `dev-agent`). Local development only. | The full list is in the [CLI reference](/docs/reference/cli/). Source: https://obop-platform.sliplane.app/docs/agents/cli/ --- # Agents ## The agent model An **agent** is anything that acts on obop with an agent token: an MCP client such as Claude Desktop or Cursor, a script that calls the REST API, or the command line. Agents work on the same records and through the same commands as people. Four rules make that safe. 1. **Scoped.** An agent token carries a list of permission groups (its _scopes_). It can only narrow what its owner may do, never widen it. Asking for a group the owner does not hold is refused. 2. **Masked.** Personal data comes back as stable placeholder tokens such as `EMAIL_1`. Financial, HR and special-category fields are withheld. An agent can still reason about records and pass tokens back, but it never sees the real values. See [Masking tokens](/docs/agents/masking/). 3. **Propose, don't commit.** An agent can run ordinary actions within its scopes, but for anything irreversible (posting an invoice, paying, refunding, sending, publishing, deleting) it can only _propose_. The server returns a plan id and changes nothing. A person approves. See [The approval loop](/docs/agents/approvals/). 4. **Same door.** REST, MCP and the CLI go through one dispatcher, so an agent has exactly the same permissions, masking and approvals whichever way it connects. ## Three ways to connect | Surface | Use it when | Page | | -------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------- | | MCP over stdio | An MCP client (Claude Desktop, Claude Code, Cursor, VS Code, Windsurf and others) should call obop as tools. | [Connect over MCP](/docs/agents/mcp/) | | REST | A script or service calls HTTP endpoints. | [Use the REST API](/docs/agents/rest/) | | CLI | A shell or a cron job calls `obop call`. | [Use the CLI](/docs/agents/cli/) | ## Set-up in five steps 1. Decide what the agent should do, and which groups that needs. Less is better. 2. [Create an agent token](/docs/agents/tokens/) with only those scopes. 3. Connect the agent over [MCP](/docs/agents/mcp/), [REST](/docs/agents/rest/) or the [CLI](/docs/agents/cli/). 4. Try a harmless read, then a change that needs approval, and approve it as a person. 5. Read [Safety best practices](/docs/agents/safety/) before you give the token to anything unattended. ## What exists today, and what does not - Built: scoped tokens, masking and restore, approval plans, MCP over stdio, REST and the CLI. - Not built yet: an HTTP (remote) MCP transport with OAuth, so hosted instances cannot yet be reached over MCP; a screen for creating or revoking agent tokens; a list of pending approvals in the interface. Agent tokens do not expire and there is no revoke command yet. These are called out again where they matter. See [Example workflows](/docs/agents/workflows/) for prompts and flows to start from, and [Troubleshooting](/docs/agents/troubleshooting/) when something does not work. Source: https://obop-platform.sliplane.app/docs/agents/ --- # Masking tokens ## What an agent receives For a field that holds personal data, the server replaces the value with a token built from the field name and a counter, such as `EMAIL_1`, `NAME_2` or `CONTACT_EMAIL_7`. Fields in a sensitive data class are left out and named in `denied` (REST) or `withheld_fields` (MCP). ```json { "denied": ["party.tax_id"], "value": { "email": "EMAIL_4", "id": "01a14650-2667-...", "name": "NAME_5", "notes": "NOTES_6" } } ``` ## Rules of the tokens - **The same value gets the same token** within one session: one agent token on one server process. Two records that mention the same email show the same token, so the agent can see they match. - **Counters are shared**, so a token's number says nothing about the record. Do not parse it. - **They are not stable across sessions.** A restart of the server, or a different agent token, starts a new table. A token an agent remembers from yesterday may not resolve today. - **They never leave the permission path.** A token only resolves if the agent could read the record the value came from. ## Passing tokens back When the agent writes, it can use a token where the value would go. The server replaces it before saving: ```bash curl -s -X POST http://127.0.0.1:8080/api/lead/create \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"title":"Support renewal","contact_name":"NAME_5","contact_email":"EMAIL_4"}' ``` A person opening that lead sees the real name and email. The agent only ever handled the tokens. A token the server cannot resolve is refused with `400 {"error":"invalid token"}`. If the agent types a real value instead (for example because a user told it), the value is saved as given and shown to the agent as a new token afterwards. ## Filtering by a masked field An agent cannot filter or sort on a masked or withheld field. The request fails as if the field did not exist: `unknown field party.email`. This is on purpose: otherwise an agent could find a hidden value by guessing and checking. ## What to put in the agent's instructions > Personal data is masked. You will see placeholders such as NAME_1 or EMAIL_2. Never try to guess or reconstruct the real value. To use a person's details in a new record, copy the placeholder. If a field is listed as withheld, tell me you cannot see it instead of asking for it. ## What masking does not do - It does not read free text. If a person types a customer's phone number into a note, that note is shown as it is unless the field is marked as personal data. - It protects fields, not reasoning. An agent that is told a real value in chat can repeat it. Keep real personal data out of prompts. - It is not a legal guarantee. Treat it as one layer; see [Security and privacy](/docs/reference/security/). Background: [Data classes and masking](/docs/concepts/data-classes-and-masking/). Source: https://obop-platform.sliplane.app/docs/agents/masking/ --- # Connect over MCP ## Remote MCP: connect by URL An instance serves the [Model Context Protocol](https://modelcontextprotocol.io/) over **Streamable HTTP** at `https://YOUR-INSTANCE/mcp`. A hosted instance (for example on Sliplane) and a local `obop serve` both have it; there is nothing to switch on. - **Authentication:** `Authorization: Bearer ` on every request, with an [agent token](/docs/agents/tokens/) (or a session token from sign-in). The token is checked exactly as for REST, so the tool list, permissions, masking and approvals are the ones the token has over [REST](/docs/agents/rest/). - **No OAuth yet.** obop has no OAuth authorization server. The endpoint answers `401` with `WWW-Authenticate: Bearer resource_metadata="https://YOUR-INSTANCE/.well-known/oauth-protected-resource"` as the MCP authorization specification asks, and that document says plainly that tokens come from `POST /api/auth/login` and `POST /api/auth/agent-token` and names no authorization server. Clients that can only connect through an OAuth browser flow (for example the "custom connector" dialog of the Claude apps) **cannot connect yet**; use a client below that accepts a header. - **Protocol revisions:** `2025-11-25`, `2025-06-18` and `2025-03-26`, negotiated in `initialize`. The newest revision, `2026-07-28`, is stateless and works differently; obop does not implement it yet. A client that speaks both falls back to `initialize` when it sees a plain `400`, which is what obop sends for an unsupported version header. - **Sessions:** `initialize` returns an `Mcp-Session-Id`; send it on every later request. The session keeps the masking vault, so a mask token such as `NAME_1` means the same thing in every call of the session and you can send it back. A different session starts a new vault. `DELETE /mcp` ends a session; an unknown or expired one answers `404`, and the client starts again with `initialize`. A session belongs to the token that opened it and expires after two hours without a request. - **Answers are plain JSON.** There is no server-to-client SSE stream (`GET /mcp` answers `405`), and JSON-RPC batches are accepted only on `2025-03-26`, the one revision that has them. - **Limits:** request bodies up to 1 MiB (`413`), 600 requests per minute per token (`429` with `Retry-After`, tunable with `OBOP_MCP_RATE_PER_MINUTE`), 25 open sessions per token. - **Browsers:** a request with an `Origin` header is refused with `403` unless the origin is the instance's own public URL (`OBOP_PUBLIC_URL`), the same origin on a loopback host, or listed in `OBOP_ALLOWED_ORIGINS` (comma separated). This protects local instances against DNS rebinding. Clients that are not browsers send no `Origin`. Behind a proxy set `OBOP_PUBLIC_URL` (for example `https://acme.example.com`) so the discovery documents name the public address. ### Tools `tools/list` shows one tool per command the token may use, named `__`: `party__search`, `lead__create`, `invoice__post`. Each has a JSON Schema `inputSchema` generated from the model: money and number fields are integers, dates are `format: date`, selects are an `enum` of their options, a ref field is a string that names its target model, and required fields are in `required`. `list` and `search` take `filter`, `order`, `limit` and `offset`; `get`, `delete` and actions take an `id`. Annotations mark reads (`readOnlyHint`) and delete (`destructiveHint`). One extra read-only tool, `obop__meta`, returns what `GET /api/meta` returns: start there. A tool the token may not use is not listed and answers like a tool that does not exist. Results have `structuredContent` and the same JSON as text: ```json { "status": "ok", "value": [{ "id": "...", "name": "NAME_1" }], "denied": ["party.tax_id"] } ``` - A step that needs a human (every delete, posting an invoice) does not run for an agent. The result is a normal one with `"status": "approval_required"`, a `plan_id` and an instruction to ask a human, who approves it in the back office or with `POST /api/approve/`. See [The approval loop](/docs/agents/approvals/). - A refused input (a bad value, a missing field) is a tool error: `isError: true` and the server's message in `structuredContent.error`. - `initialize` also returns `instructions`, a short text that tells the model how to use the instance. `tools/list` is paged with a `cursor` only when a token sees more than 200 tools. The list does not announce changes (`listChanged` is `false`): after installing a module, start a new session. ### Check it with curl ```bash export OBOP_URL=https://YOUR-INSTANCE export OBOP_TOKEN=obop_... curl -si -X POST "$OBOP_URL/mcp" \ -H "Authorization: Bearer $OBOP_TOKEN" \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' ``` Copy the `mcp-session-id` header of the answer, then send `{"jsonrpc":"2.0","method":"notifications/initialized"}` (answer `202`), then `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`, both with `-H "Mcp-Session-Id: "`. ### Remote client configuration Replace `https://YOUR-INSTANCE/mcp` and keep the token in an environment variable or a secret store. The formats below were taken from each client's documentation on 2026-10-17. We ran obop only against the MCP Inspector command line client (`vp dlx @modelcontextprotocol/inspector --cli URL --transport http --header "Authorization: Bearer ..." --method tools/list`) and our own Playwright tests. We did not start the other clients, so each snippet is **documented, not tried**. #### Claude Code ```bash claude mcp add --transport http obop https://YOUR-INSTANCE/mcp \ --header "Authorization: Bearer $OBOP_TOKEN" ``` Add `--scope project` to write a shared `.mcp.json` or `--scope user` to use it in all projects. In a shared file read the token from the environment so it is not committed: ```json { "mcpServers": { "obop": { "type": "http", "url": "https://YOUR-INSTANCE/mcp", "headers": { "Authorization": "Bearer ${OBOP_TOKEN}" } } } } ``` If the server rejects the header, Claude Code reports a failed connection instead of starting OAuth. Check with `/mcp`. #### Claude Desktop and claude.ai The apps add remote servers as _custom connectors_ (Settings, Connectors, Add custom connector) and authenticate them with OAuth. A static bearer header is not offered there, and obop has no OAuth server, so **a hosted obop instance cannot be added as a connector yet**. The stdio setup [below](#claude-desktop-stdio) works in Claude Desktop with a local binary. A bridge such as the community `mcp-remote` package can add a header to a remote server for clients that only speak stdio; we have not tested it with obop. #### Cursor `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (all projects): ```json { "mcpServers": { "obop": { "url": "https://YOUR-INSTANCE/mcp", "headers": { "Authorization": "Bearer ${env:OBOP_TOKEN}" } } } } ``` #### VS Code `.vscode/mcp.json`. The top-level key is `servers`, and an input prompts for the token so it is not written to disk: ```json { "inputs": [ { "type": "promptString", "id": "obop-token", "description": "obop agent token", "password": true } ], "servers": { "obop": { "type": "http", "url": "https://YOUR-INSTANCE/mcp", "headers": { "Authorization": "Bearer ${input:obop-token}" } } } } ``` VS Code's documentation did not show one complete example with headers and an input, so this snippet is assembled from its field tables. #### Windsurf Windsurf's documentation now lives on the Devin Desktop site. It names `~/.config/devin/mcp_config.json` (macOS and Linux) and `%APPDATA%\devin\mcp_config.json` (Windows); older Windsurf installs used `~/.codeium/windsurf/mcp_config.json`. Check which one your version reads. Remote servers use `serverUrl` (or `url`) and `headers`, and `${env:NAME}` is expanded: ```json { "mcpServers": { "obop": { "serverUrl": "https://YOUR-INSTANCE/mcp", "headers": { "Authorization": "Bearer ${env:OBOP_TOKEN}" } } } } ``` #### Codex CLI In `~/.codex/config.toml`. `bearer_token_env_var` names the environment variable that holds the token; Codex sends it as an `Authorization` bearer token: ```toml [mcp_servers.obop] url = "https://YOUR-INSTANCE/mcp" bearer_token_env_var = "OBOP_TOKEN" ``` `codex mcp add` documents `--url` with OAuth options only, so use the file for a bearer token. #### Gemini CLI The documentation we read says Gemini CLI was replaced by Antigravity CLI on 2026-06-18; check the page of the tool you use. The Gemini CLI format is `httpUrl` for Streamable HTTP (`url` is for the older SSE transport), in `~/.gemini/settings.json`: ```json { "mcpServers": { "obop": { "httpUrl": "https://YOUR-INSTANCE/mcp", "headers": { "Authorization": "Bearer your-agent-token" } } } } ``` or `gemini mcp add --transport http --header "Authorization: Bearer $OBOP_TOKEN" obop https://YOUR-INSTANCE/mcp`. We could not confirm that this file expands environment variables in `headers`, so the token is written out here. ### Discovery An instance publishes these without a token: | URL | What | | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/.well-known/oauth-protected-resource` | OAuth protected resource metadata (RFC 9728) for `/mcp`. States that there is no authorization server. Also at `/.well-known/oauth-protected-resource/mcp`. | | `/.well-known/mcp/server-card.json` | MCP server card: name, version, the `streamable-http` endpoint, protocol versions, the `Authorization` header. Also at `/mcp/server-card`. | | `/.well-known/ai-catalog.json` | A one-entry AI Catalog that points at the server card. | | `/api/openapi.json`, `/api/meta` | The OpenAPI document has an `x-mcp` field with the endpoint, transport and protocol versions; `GET /api/meta` carries a `Link` header with `rel="mcp"`. | The server card is an extension of the MCP specification that is still being developed (SEP-2127, kept in an incubation repository), so its shape may change; the file says so in its `$comment`. It also carries plain `serverInfo`, `transport`, `endpoint` and `capabilities` fields from an earlier draft, for scanners that look for them. ## MCP over stdio (local binary) Use this when the client can only start a local program, or when you run obop on the same machine. The remote server above needs none of it. ### How it works `obop mcp` is an MCP server that talks JSON-RPC over standard input and output. An MCP client starts it as a child process. It: - opens the instance's database itself, so it must run on a machine that can reach the database (SQLite file or PostgreSQL URL); - identifies the agent by an agent token; - lists the same tools, with the same schemas, as the remote server (both transports run one code path): one tool per command that token may use, named `__`. The double underscore stands where the REST path has a slash; - answers `tools/call` through the same dispatcher as REST, so masking and approvals apply. The mask tokens are stable for as long as the process runs. ### Before you start 1. A running instance database, and the `obop` binary on the machine that runs the MCP client (or use its absolute path). 2. An [agent token](/docs/agents/tokens/) with the scopes you want. 3. The database address, as `OBOP_DATABASE_URL` or `--db`. The default is `sqlite://obop.db` in the current directory, so give an absolute path: `sqlite:///home/me/obop.db`. The command every client runs is: ```bash obop mcp --db sqlite:///home/me/obop.db --token obop_... ``` It reads `OBOP_TOKEN` and `OBOP_DATABASE_URL` from the environment if the flags are absent. Prefer the environment: it keeps the token out of process listings. Check it by hand first. This sends an `initialize` and a `tools/list` request and prints the answers: ```bash printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ | OBOP_TOKEN=obop_... OBOP_DATABASE_URL=sqlite:///home/me/obop.db obop mcp ``` The server answers with the revision the client asks for if it is one of `2025-11-25`, `2025-06-18` and `2025-03-26`, and supports `initialize`, `ping`, `tools/list` and `tools/call`. The arguments of a tool are the same JSON as the [REST body](/docs/reference/rest-api/). The snippets below use `/usr/local/bin/obop` and a placeholder token. Replace both. Formats were checked against each client's documentation on 2026-10-16; where something could not be confirmed it is marked. ### Claude Desktop (stdio) Open _Settings_, then _Developer_, then _Edit Config_. The file is `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS and `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Add: ```json { "mcpServers": { "obop": { "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "obop_your_agent_token", "OBOP_DATABASE_URL": "sqlite:///Users/me/obop.db" } } } } ``` Quit and restart Claude Desktop. Claude asks your approval before it uses a tool. ### Claude Code (stdio) Use the command line. Everything after `--` is the server command: ```bash claude mcp add --transport stdio \ --env OBOP_TOKEN=obop_your_agent_token \ --env OBOP_DATABASE_URL=sqlite:///home/me/obop.db \ obop -- /usr/local/bin/obop mcp ``` Put at least one other option (here `--transport stdio`) between the last `--env` and the server name. Add `--scope project` to write a shared `.mcp.json` instead, `--scope user` to use it in all projects. A project file looks like this; the `${...}` form reads the value from your environment so the token is not committed: ```json { "mcpServers": { "obop": { "type": "stdio", "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "${OBOP_TOKEN}", "OBOP_DATABASE_URL": "sqlite:///home/me/obop.db" } } } } ``` Check it with `claude mcp get obop` or `/mcp` inside Claude Code. ### Cursor (stdio) Create `.cursor/mcp.json` in a project, or `~/.cursor/mcp.json` for every project: ```json { "mcpServers": { "obop": { "type": "stdio", "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "${env:OBOP_TOKEN}", "OBOP_DATABASE_URL": "sqlite:///Users/me/obop.db" } } } } ``` Cursor's own examples omit `type` for stdio servers; its field table lists it. `${env:NAME}` reads from the environment Cursor was started in. ### VS Code (stdio) Create `.vscode/mcp.json`. VS Code's top-level key is `servers`, not `mcpServers`, and it can prompt for the token so it is never written to disk: ```json { "inputs": [ { "type": "promptString", "id": "obop-token", "description": "obop agent token", "password": true } ], "servers": { "obop": { "type": "stdio", "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "${input:obop-token}", "OBOP_DATABASE_URL": "sqlite:///Users/me/obop.db" } } } } ``` VS Code's guided _MCP: Add Server_ command may suggest a portable `.mcp.json` with the `mcpServers` format instead; both are accepted according to its documentation. ### Windsurf (stdio) Windsurf's documentation has moved to the Devin Desktop site and describes the file as `mcp_config.json`. We could not confirm the current file location for Windsurf itself. Windsurf historically used `~/.codeium/windsurf/mcp_config.json`; the Devin Desktop page gives `~/.config/devin/mcp_config.json` (macOS and Linux) and `%APPDATA%\devin\mcp_config.json` (Windows). Check the location in your version's settings. The format is the same `mcpServers` shape: ```json { "mcpServers": { "obop": { "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "${env:OBOP_TOKEN}", "OBOP_DATABASE_URL": "sqlite:///Users/me/obop.db" } } } } ``` ### Codex CLI (stdio) Add a table to `~/.codex/config.toml` (or `.codex/config.toml` in a trusted project), or run `codex mcp add`: ```toml [mcp_servers.obop] command = "/usr/local/bin/obop" args = ["mcp"] env_vars = ["OBOP_TOKEN"] [mcp_servers.obop.env] OBOP_DATABASE_URL = "sqlite:///home/me/obop.db" ``` `env_vars` forwards `OBOP_TOKEN` from your shell instead of writing it into the file. ### Gemini CLI (stdio) Add to `~/.gemini/settings.json`: ```json { "mcpServers": { "obop": { "command": "/usr/local/bin/obop", "args": ["mcp"], "env": { "OBOP_TOKEN": "$OBOP_TOKEN", "OBOP_DATABASE_URL": "sqlite:///home/me/obop.db" } } } } ``` Gemini CLI's documentation says it was replaced by another tool in 2026, so check that page for your version. We have not run obop against it. ### Any other MCP client obop is a plain stdio server. Whatever the client's file looks like, it needs a `command` (`obop`), `args` (`["mcp"]`) and two environment variables. If the client only supports remote servers, use the remote server above. ## What the agent sees After connecting, ask the agent to list its tools. You should see only the commands its scopes allow, with a note on those that need approval. Try a read first, for example _"List the three newest leads"_, then see [The approval loop](/docs/agents/approvals/). If the client shows no tools or an error, go to [Troubleshooting](/docs/agents/troubleshooting/). ## Verified and not verified | Item | Status | | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `obop mcp` handshake, `tools/list`, `tools/call`, approval result | Run against a real local instance. | | Remote `/mcp`: handshake, tools, masking, approval loop, sessions, 401, origin check, limits | Automated tests (Rust over HTTP, Playwright) against a real instance. | | Remote `/mcp` with a real MCP client | MCP Inspector command line client 2.11.0: `initialize` and `tools/list`, `tools/call`. | | Official MCP conformance suite | Not meaningful: it needs a server without authentication and its own fixture tools. | | Remote configuration of Claude Code, Cursor, VS Code, Windsurf, Codex CLI, Gemini CLI | Taken from each client's documentation; the clients were not started. | | Stdio configuration formats | Taken from each client's documentation. We did not start every client. | | Windsurf file location | Unconfirmed, see above. | | Claude Desktop and claude.ai connectors | Need OAuth; obop cannot be added there yet. | Source: https://obop-platform.sliplane.app/docs/agents/mcp/ --- # Use the REST API ## The shape of every call ```text POST /api/{model}/{command} Authorization: Bearer Content-Type: application/json { ...input... } ``` `model` and `command` are the two halves of the command id: `lead.search` is `POST /api/lead/search`. The response is JSON: ```json {"denied": [], "value": ...} ``` `value` is the result and `denied` lists field names withheld from this caller. Other endpoints: `GET /api/commands` (what this token may call), `GET /api/meta` (models, fields and actions this token may see), `POST /api/approve/{plan_id}` (a person approves a plan) and the two `/api/auth/` endpoints from [Create an agent token](/docs/agents/tokens/). The full list is in the [REST reference](/docs/reference/rest-api/). ## See what the token may do ```bash curl -s http://127.0.0.1:8080/api/commands -H "Authorization: Bearer $AGENT_TOKEN" ``` ```json { "commands": [ { "id": "lead.create", "description": "Create a record", "needs_approval": false }, { "id": "lead.delete", "description": "Delete a record that no other record refers to", "needs_approval": true }, { "id": "lead.qualify", "description": "Move a new lead to the qualified stage", "needs_approval": false } ] } ``` `needs_approval` tells the agent in advance which commands it can only propose. ## Read, with masking ```bash curl -s -X POST http://127.0.0.1:8080/api/party/list \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' -d '{}' ``` ```json { "denied": ["party.tax_id"], "value": [ { "email": "EMAIL_1", "id": "01a1464f-b0c8-700e-9f39-0cbf8e848c0b", "name": "NAME_2", "notes": "NOTES_3" } ] } ``` The tax identifier is withheld and named in `denied`. Name, email and notes are personal data and come back as tokens. ## Search and filter `search` takes the same input as `list` and also returns the total before paging. Filters and sorting only work on fields the agent can read in clear. ```bash curl -s -X POST http://127.0.0.1:8080/api/lead/search \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"filter":[{"field":"stage","op":"eq","value":"new"}], "order":[{"field":"expected_revenue_minor","dir":"desc"}], "limit":10}' ``` ```json { "denied": [], "value": { "rows": [ { "id": "...", "stage": "new", "title": "Fleet telematics", "expected_revenue_minor": 3125000, "contact_email": "CONTACT_EMAIL_7", "contact_name": "CONTACT_NAME_8" } ], "total": 1 } } ``` Operators: `eq`, `ne`, `contains`, `in`, `gt`, `gte`, `lt`, `lte`, `is_null`. At most 50 conditions, 5 order keys and 5000 rows per call. ## Create and update ```bash curl -s -X POST http://127.0.0.1:8080/api/lead/create \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"title":"Support renewal","contact_name":"NAME_2","contact_email":"EMAIL_1"}' ``` Money is an integer in minor units (`3125000` is 31,250.00). Dates are `YYYY-MM-DD`. The tokens `NAME_2` and `EMAIL_1` were read earlier; the server swaps them for the real values on write and the agent never sees them. See [Masking tokens](/docs/agents/masking/). `update` takes the record `id` and the fields to change: ```bash curl -s -X POST http://127.0.0.1:8080/api/lead/update \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"id":"01a14650-41ae-7455-9566-1ec1a313bdf3","expected_revenue_minor":3500000}' ``` ## Run a workflow action ```bash curl -s -X POST http://127.0.0.1:8080/api/lead/qualify \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"id":"01a14650-41ae-7455-9566-1ec1a313bdf3"}' ``` An action that needs approval answers `202` instead; see the next section. ## Propose something that needs approval ```bash curl -si -X POST http://127.0.0.1:8080/api/lead/delete \ -H "Authorization: Bearer $AGENT_TOKEN" -H 'content-type: application/json' \ -d '{"id":"01a14650-41ae-7455-9566-1ec1a313bdf3"}' ``` ```text HTTP/1.1 202 Accepted {"approval_required":true,"plan_id":"01a14650-41d3-7205-b97a-20fa74bb3420"} ``` Nothing has changed. Give the plan id to a person; see [The approval loop](/docs/agents/approvals/). ## Status codes | Code | Meaning | | ---- | --------------------------------------------------------------------------------------------------------------------- | | 200 | Done. Read `value` and `denied`. | | 202 | Approval required. `plan_id` is in the body. Nothing changed. | | 400 | The input was refused. `error` says why, for example `lead.title is required` or `invalid token`. | | 401 | No bearer token, or an unknown, expired or revoked one. | | 404 | The command does not exist, is not visible to this token, or the plan is unknown. All three look the same on purpose. | | 415 | The body was not sent as `application/json`. | | 429 | Too many failed sign-ins (five in a minute lock a username for a minute). | ## Tips for agents - Start with `GET /api/commands` and `GET /api/meta` so the agent learns the model instead of guessing. - Send small, specific calls. A `search` with a `filter` and a `limit` beats listing everything. - Treat `denied` as information: that field exists but is not for this agent. Source: https://obop-platform.sliplane.app/docs/agents/rest/ --- # Safety best practices ## Give the least that works - **Scopes are the main control.** An agent that triages tickets needs `helpdesk.agent`, not `platform.admin`. If you are unsure whether a group is needed, leave it out and add it when a call fails. - **Do not hand an agent an administrator's groups.** Installing and removing modules needs `platform.admin`; agents can only propose it, but there is no reason to widen the blast radius. - **Check the result.** `GET /api/commands` with the new token lists exactly what it can call. Read that list before you use the token. ## One token per agent and per task A token has a label, but you cannot list or revoke tokens yet. Separate tokens limit damage and make an investigation possible: - Create a fresh token for each agent and each unattended job. - Create short-lived work with a token you will stop using, and stop using it. - If you share one token between agents, you cannot tell their actions apart and you cannot cut one off. ## Keep tokens secret - Use environment variables or a secret store. Never commit a token, paste it into a chat, or put it in a prompt. - In an MCP client config, reference the environment where the client allows it (`${env:OBOP_TOKEN}`, `${input:...}`) so the file holds no secret. - Tokens do not expire. A leaked token works until its owner is removed. ## If a token leaks There is no revoke command yet. The ways to stop a token today are: 1. Delete or disable the owning user so that the token stops resolving (a token stops working when its owner is deleted). 2. Rotate the whole instance database credentials if the leak is wider than one token. Then review what the token did: records changed by an agent are not yet marked as such in the interface, so compare against your backups and the approvals you granted. A revoke command and an audit view are on the [roadmap](/roadmap/). ## Review every approval Approval exists so that a person looks. Make it a real look: - Ask the agent to describe the change in plain words, with the record it concerns, before you approve. - Approve in a separate step from the conversation with the agent, so you are not pressed into saying yes. - Do not approve a plan you did not expect. Nothing is lost by asking again. ## Keep personal data out of the loop - Do not paste real customer data into prompts. Let the agent read tokens. - Classify custom fields honestly. A free-text field that may contain personal data should be marked as personal data, or the agent will see it in clear. - Be careful with `notes` fields: people type anything in them. ## Test on a copy Point a new agent at a throwaway SQLite instance (`obop serve --demo` makes fake data) before you let it near a real business. Use the [CLI](/docs/agents/cli/) or [REST](/docs/agents/rest/) to rehearse the flow. ## Know the limits - No row-level permissions: an agent with `crm.user` can read every lead. - Actions change one field and cannot send email or call other systems, so an agent cannot cause those side effects through obop. - Masking works on classified fields, not on free text. - Rate limiting applies to failed sign-ins only. There are no per-token quotas yet. Source: https://obop-platform.sliplane.app/docs/agents/safety/ --- # Create an agent token ## What a token is - It belongs to the person who created it (its _owner_) and acts with a subset of that person's groups. - It is shown **once**. obop stores only a hash, so a lost token cannot be recovered; create a new one. - It starts with `obop_`. Treat it like a password. - Only a person can create tokens. An agent cannot mint another token. - It does not expire, and it stops working when its owner is deleted. There is no command to revoke a single token yet (see [Safety](/docs/agents/safety/)). ## Choose the scopes Scopes are [permission groups](/docs/concepts/permission-groups/). Look at what the agent should do and give it only those groups. Common groups of the built-in apps: | Group | Lets the agent | | ------------------- | ------------------------------------------------------- | | `base.user` | read and write Contacts (personal data stays masked) | | `crm.user` | read and write leads and move them through the pipeline | | `sales.user` | read and write quotations and orders | | `invoicing.user` | read and write invoices and cancel drafts | | `invoicing.manager` | propose posting an invoice (a person still approves) | | `product.user` | read products and prices | Installed packages add their own groups, for example `helpdesk.agent`. The [app pages](/apps/) list the groups of each app. ## Over REST Sign in as a person, then ask for a token. This needs a running instance; the examples use a local one on port 8080. ```bash curl -s -X POST http://127.0.0.1:8080/api/auth/login \ -H 'content-type: application/json' \ -d '{"username":"alice","password":"your password"}' ``` The answer is `{"token":"obop_..."}`: a session token that lasts 12 hours. Use it as a bearer token to create the agent token: ```bash curl -s -X POST http://127.0.0.1:8080/api/auth/agent-token \ -H "Authorization: Bearer $SESSION_TOKEN" \ -H 'content-type: application/json' \ -d '{"scopes":["base.user","crm.user"],"label":"triage-bot"}' ``` ```json { "token": "obop_1e00c3..." } ``` `scopes` is required and must name at least one group that you hold. `label` is free text. On a hosted instance use its address instead of `127.0.0.1:8080`, and sign in with your sign-up email as the username. ## With the CLI If you run obop yourself you can issue a token straight from the command line. It talks to the database directly, so run it where the database is reachable: ```bash obop agent-token alice --scopes base.user,crm.user --label triage-bot ``` ```text obop_1e00c32430532fdcef... shown once; store it like a password ``` Add `--db postgres://...` (or set `OBOP_DATABASE_URL`) if the instance does not use the default `sqlite://obop.db`. ## Use the token for MCP The same token connects an MCP client to the instance's remote MCP server, `https://YOUR-INSTANCE/mcp`: send it as `Authorization: Bearer obop_...` (clients have a `headers` field or a bearer-token variable for it). A token created with the CLI or over REST works for both; there is nothing MCP-specific to create. Keep these points in mind: - Create a separate token per client and give it only the scopes that client needs. The MCP tool list is exactly the commands the scopes allow. - A session token from sign-in also works, but it expires after 12 hours; an agent token does not. - The token must be sent on every request, not only the first; clients do this once it is in their configuration. - There is no OAuth sign-in for MCP yet, so clients that only offer a browser login cannot connect. See [Connect over MCP](/docs/agents/mcp/). ## Store it safely - Put it in an environment variable or a secret store, not in a repository. - Prefer one token per agent and per task, so you can tell them apart and remove one without affecting the rest. - Tokens in a client config file are readable by anything that can read the file. ## Errors | Answer | Meaning | | ------------------------------------------------------------------------------ | ------------------------------------------- | | `400 {"error":"an agent token needs at least one scope"}` | `scopes` was empty. | | `400 {"error":"you do not hold invoicing.manager, so you cannot delegate it"}` | You asked for a group you do not hold. | | `400 {"error":"only a human user can issue agent tokens"}` | You used an agent token to ask for another. | | `401` | Missing, wrong or expired session token. | Next: [connect over MCP](/docs/agents/mcp/) or [use the REST API](/docs/agents/rest/). Source: https://obop-platform.sliplane.app/docs/agents/tokens/ --- # Troubleshooting ## The MCP client shows no tools, or says the server failed 1. **Run the server by hand.** Use the handshake command from [Connect over MCP](/docs/agents/mcp/). If it prints a JSON answer, the server is fine and the client config is wrong. If it prints an error, fix that first. 2. **Use absolute paths.** Clients start the server from an unknown working directory. Use the full path to `obop` and an absolute database path: `sqlite:///home/me/obop.db` (three slashes, then the absolute path). 3. **`no valid token`.** The message reads `no valid token: pass --token (or OBOP_TOKEN); see obop admin create and obop agent-token`. The token is missing, wrong, revoked or its owner was deleted. Create a new one. 4. **The database is empty or new.** If `database: sqlite` points at a different file than your instance, you see no users and no records. Check `OBOP_DATABASE_URL`. 5. **Restart the client.** Most clients read the config only at start. 6. **Read the client's log.** Claude Desktop writes `mcp.log` and one log per server (macOS: `~/Library/Logs/Claude`). The server prints `database: sqlite` on stderr when it starts; that line is normal. ## The agent sees fewer tools than expected The list is the commands the token may use. Add the group that the missing command needs (create a new token), or check the model's `read_group` and `write_group` on the [app page](/apps/). Hidden and forbidden commands are indistinguishable from unknown ones (`404` or MCP `unknown tool`) on purpose. ## REST answers | You get | Likely cause | | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `401` | No `Authorization: Bearer ...` header, or a wrong, expired or revoked token. Session tokens last 12 hours; agent tokens do not expire. | | `404 {"error":"not found"}` | Wrong model or command name, the token lacks the group, or the command is not on this surface. | | `415` | Send `Content-Type: application/json`. | | `400 {"error":"invalid token"}` | A masking token was not recognised: it is from another session or an older server process. Read the record again. | | `400 {"error":"unknown field party.email"}` | You filtered or sorted on a field the agent cannot read in clear (masked, withheld or not permitted). | | `400 {"error":"lead.title is required"}` | A required field is missing. | | `400 ... starts as "new" when a record is created` | You tried to set a governed state on create. Leave it out and use an action. | | `400 ... cannot change from "new" to "won"` | No action moves it that way. The message lists where the record can go. | | `429` | Five failed sign-ins for a username lock it for a minute. | | `202` | Not an error: approval is required. See [The approval loop](/docs/agents/approvals/). | ## An approval plan was lost Pending plans live in the server's memory and disappear when the server restarts. Ask the agent to propose again. ## `approve` returns 404 The plan id is unknown or was already used, an agent tried to approve (only a person can), or the approver does not hold the action's group. ## The agent keeps asking for data it cannot see Tell it in its instructions that personal data is masked, that withheld fields are not available, and to report instead of asking. See [Masking tokens](/docs/agents/masking/). ## Local development quirks - `--dev-auth` swaps real sign-in for fixed tokens and only binds to localhost. Do not use it for real agents. - A SQLite file is opened by every process that points at it. `obop serve` and `obop mcp` can share one file on one machine. - The CLI prints `database: sqlite` first on every run. That line goes to stderr and does not end up in piped output. ## Still stuck Check the [FAQ](/docs/reference/faq/), search the [issues](https://github.com/SpaceCorps/obop/issues) or ask in the [community](/about/community/). Source: https://obop-platform.sliplane.app/docs/agents/troubleshooting/ --- # Example workflows These examples assume the agent is connected over [MCP](/docs/agents/mcp/) or [REST](/docs/agents/rest/). Tool names are `model__command` over MCP and `/api/model/command` over REST. ## Triage new helpdesk tickets **Needs:** the Helpdesk package installed. **Scopes:** `helpdesk.agent`. **Prompt** > You help the support team. List helpdesk tickets in state `open` that have no category. For each, read the subject and description, set `category` (question, incident, problem or feature_request) and `priority` (low, normal, high or urgent) and give me a one-sentence summary in your final report. Do not change the state or any other field. Personal data is masked; never try to find out who the customer is. At the end, list what you changed. **What the agent does** 1. `helpdesk_ticket__search` with `{"filter":[{"field":"state","op":"eq","value":"open"},{"field":"category","op":"is_null"}],"limit":25}`. 2. For each row, `helpdesk_ticket__update` with `{"id":"...","category":"incident","priority":"high"}`. 3. Reports the list. **Where a person steps in:** replying to customers. Helpdesk has no sending action, and this agent has no way to contact anyone. ## Draft a quotation **Scopes:** `crm.user`, `sales.user`, `base.user`, `product.user`. **Prompt** > The lead "Warehouse pilot" has reached the qualified stage. The customer is the party with id 01a1464f-b0c8-700e-9f39-0cbf8e848c0b. Look up the list prices of the products I name below and create a draft sale order for that party with the matching total and currency EUR. Leave it in draft. Tell me the order id. **What the agent does** 1. `lead__search` to confirm the lead and its stage. 2. `product__search` for list prices (`list_price_minor`, an integer in minor units). 3. `sale_order__create` with `party_id`, `total_minor` and `currency`. The state starts at `draft` and the agent cannot set it to anything else. The prompt gives the party id because masked contact details on a lead cannot be matched to a party: tokens differ per field, and an agent cannot search masked fields. **Where a person steps in:** confirming the order. The agent could run `sale_order__confirm` too, because Sales has no approval step; if you want a person to confirm, do not put `confirm` in the prompt, or keep the agent's scope to a read-only group. ## Prepare invoices for approval **Scopes:** `invoicing.user`, `invoicing.manager`. **Prompt** > List invoices in draft. For each, check that total_minor is greater than zero and that it refers to a sale order. Propose posting the ones that pass and give me a table of invoice id, total and plan id. Do not try to post anything yourself. **What the agent does:** `invoice__search` for drafts, then `invoice__post` for each that passes. Every call returns `approval_required` with a plan id. The agent lists the plans. A person with `invoicing.manager` approves the ones they agree with, using [the approval loop](/docs/agents/approvals/). The legal number is assigned only when the plan runs. ## Keep a CRM pipeline tidy **Scopes:** `crm.user`. **Prompt** > Find leads in `qualified` with no expected revenue and list them for me. Do not edit them. Then find leads in `new` that have no contact name and suggest which to archive. Do not delete anything; if you think a lead should go, say which and why. An agent can only propose a delete, so even a mistaken instruction does no immediate harm. The plan appears as `approval_required`. ## A scheduled daily summary over the CLI **Scopes:** read-only groups such as `crm.user`. ```bash export OBOP_TOKEN=obop_read_only_token obop call lead search 'filter=[{"field":"stage","op":"eq","value":"won"}]' limit=50 > won.json ``` A script feeds `won.json` to a model to write the summary. Because leads come back masked, the summary refers to tokens, not names. Resolve names yourself afterwards if you need them. ## Writing good agent instructions - State the goal and the boundary: what to do, and what never to do. - Name the states and fields you mean (`state`, `stage`, `category`), so the agent does not guess. - Say what to do when something is withheld or needs approval: report it and stop. - Ask for a short report at the end. It is what the approving person reads. See [Safety best practices](/docs/agents/safety/) and [Troubleshooting](/docs/agents/troubleshooting/). Source: https://obop-platform.sliplane.app/docs/agents/workflows/ --- # Apps and modules An **app** is what people see in the launcher. A **module** is how it is built. In the interface obop says "app"; in the API and in files it says "module". ## What a module declares A module is described by models. A model has fields, and fields have a kind, a description, a data class and a permission group. A model can also have actions that move a record from one state to another. From that description obop generates, for every model: - six commands: `get`, `list`, `search`, `create`, `update` and `delete` (for the model `lead`: `lead.get`, `lead.list` and so on); - one command per action, for example `lead.qualify`; - the same commands as REST endpoints, MCP tools and CLI commands; - screens: list, board, form, status bar and buttons. Permissions, masking, approvals and audit are applied for free, because they live in the dispatcher and not in each app. ## Built in and installable | Kind | Apps | Notes | | ---------------- | ----------------------------------------- | ------------------------------------------------------------------------------------- | | Built in | Contacts, Products, CRM, Sales, Invoicing | Written in Rust, always present, cannot be removed. | | Bundled packages | The other 22 apps | Plain `obop.toml` files in the `packages/` directory, installed from the marketplace. | | Your own | Anything you write | Upload a file, or publish your own marketplace. | A package is one text file. It contains no code, so an administrator can install it at runtime. Code tiers (expressions, WebAssembly) are planned but not built, which is why packages cannot yet compute totals, generate sequences or call out to other systems. The [roadmap](/roadmap/) is honest about that. ## Installing and removing Administrators install, upgrade and uninstall packages from _Customize_, or with the CLI (`obop modules install-from ID`, `obop modules install FILE`, `obop modules uninstall ID`). Install checks the file first: every field must have a description, a data class and a permission group, personal data may not be classed `none`, and dependencies must be installed. Uninstalling removes the commands and screens but keeps the data in the database. Installing the module again brings the records back. There are no data migrations yet: raise the `version` and install again to upgrade, and prefer adding optional fields. ## Keep reading - [Browse the apps](/apps/) - [Module manifest reference](/docs/reference/module-manifest/) - [Marketplace](/marketplace/) Source: https://obop-platform.sliplane.app/docs/concepts/apps-and-modules/ --- # Data classes and masking ## Data classes Every field in every module declares a **data class**. It says how sensitive the value is, and it drives masking for agents. A field without a class cannot be installed. | Class | Use for | Examples | | ------------------ | -------------------------------------------------------------- | ------------------------------ | | `none` | Not personal and not otherwise sensitive. Must be written out. | Product code, state, quantity | | `public_directory` | Directory data every internal user may see. | Employee name, work email | | `internal` | Business data about people and work. | Ticket requester, site address | | `confidential_hr` | Personal employment facts that are not money. | Contract type, national id | | `special_category` | Health, union, biometrics. | Sick-leave flag | | `financial` | Money owed to or held for a named person; bank accounts. | Salary, IBAN, tax identifier | | `secret` | Credentials and keys. | API keys | A field can also be marked `personal_data = true`: it is about an identifiable person. A personal-data field may never be class `none`. `confidential_hr`, `special_category` and `financial` are the **sensitive classes**. `secret` is write-only. ## What people and agents see | Reader | `none`, `internal`, `public_directory` | Personal data | Sensitive classes | `secret` | | -------- | -------------------------------------- | ----------------- | ------------------------------- | -------------- | | A person | in clear | in clear | in clear, if their group allows | never returned | | An agent | in clear | masked as a token | withheld | withheld | A person sees real values under their normal permissions. An agent token (see [Agents](/docs/agents/)) gets: - **Masked** values for personal data, such as `NAME_2` or `EMAIL_1`. A token is stable for the same value within one session of that agent token, so the agent can still tell that two records mention the same person. - **Withheld** fields for sensitive classes. The response lists the field names that were withheld (`denied` in REST, `withheld_fields` over MCP). ## Writing tokens back An agent can pass a token in a write. The server swaps it for the real value if the agent could have read the record, so an agent can copy a contact into a new lead without ever seeing the contact's name or email. A token the server does not know is refused with `invalid token`. Tokens are kept in memory per agent token and per server process. A restart, or a different agent token, starts with a fresh table, so an old token stops resolving. ## Filtering and searching Filters and sorting run on the real values before masking. To stop an agent guessing masked values, it may only filter and sort on fields it can read in clear. Asking for any other field fails with the same error as a field that does not exist. ## Limits, honestly - Masking is by field class and the personal-data flag. It does not look inside free text: a note typed into a `none` field is shown as it is. Classify free-text fields that may contain personal data as personal data. - Tokens are not stable across restarts or across agent tokens. - Erasure: Contacts has an `erase` action that anonymises a record in place; other apps do not yet. See also [Masking tokens](/docs/agents/masking/) and [Security and privacy](/docs/reference/security/). Source: https://obop-platform.sliplane.app/docs/concepts/data-classes-and-masking/ --- # Concepts obop has a small number of ideas. Once you know them, every app works the same way. - [Apps and modules](/docs/concepts/apps-and-modules/): an app is a module that declares models, fields and actions. Everything else is generated. - [Instances](/docs/concepts/instances/): one business, one database, one address. - [Data classes and masking](/docs/concepts/data-classes-and-masking/): every field says how sensitive it is, and agents see masked values. - [Permission groups](/docs/concepts/permission-groups/): who may read, write and run steps. - [Workflows and approvals](/docs/concepts/workflows-and-approvals/): state changes are named actions, and some need a person. ## The one rule behind all of it Every surface goes through one command dispatcher. The web app, the REST API, the MCP server and the command line all call the same commands, with the same permission checks, the same masking and the same approval rules. A module cannot add a side door, and a field without a data class cannot be installed. Source: https://obop-platform.sliplane.app/docs/concepts/ --- # Instances An **instance** is one running obop for one business. It has its own database, its own users, its own installed apps and its own address. ## The control plane The hosted service is a **control plane**, a second mode of the same program (`obop platform serve`). It does three things: 1. People sign up with an email and a password. 2. Each person can create, list and delete their own instances. 3. For each instance it asks a _provisioner_ to start something. There are three provisioners. `local` starts child `obop serve` processes with SQLite on one machine. `sliplane` creates a database on a shared PostgreSQL server and a service on Sliplane for each instance. `fake` is for tests. The password you signed up with is copied into the new instance as a hash, so the same email and password sign in to the instance, where you are its first administrator (`platform.admin`). The control plane never sees your password after sign-up. ## One database per business On hosted PostgreSQL every business gets its own database and its own database role on a shared server. An instance cannot read another instance's data, because it has no credentials for it. Records are stored as JSON documents until the schema manager exists, so column types in the database are not enforced by the database; the command layer enforces them. ## Self-hosted Self-hosting is the same program without the control plane: `obop serve` is one instance. Use SQLite to try it and PostgreSQL (`OBOP_DATABASE_URL=postgres://...`) for real use. See [Getting started](/docs/getting-started/) and [Self-hosting and operations](/docs/reference/self-hosting/). ## Known limits - Creating an instance builds the Rust workspace on the host, which is slow until a prebuilt image registry is used. - Upgrading hosted instances is manual. - Nothing backs up the hosted database server yet. - TLS ends at the hosting platform's proxy. Source: https://obop-platform.sliplane.app/docs/concepts/instances/ --- # Permission groups A **permission group** is a name of the form `module.role`, for example `crm.user`, `helpdesk.agent` or `invoicing.manager`. A group exists as soon as something refers to it. Users hold groups, and so do agent tokens. ## Three places that name a group 1. **Model:** `read_group` allows `get`, `list` and `search`. `write_group` allows `create`, `update` and `delete`. 2. **Field:** `group` says who sees the field. It defaults to the model's read group. A field a caller cannot read is not even described to them. 3. **Action:** `group` says who may run a workflow step. It defaults to the model's write group. This is how posting or paying can need a stricter group than editing. ## Conventions `.user` is the day-to-day role and `.manager` handles configuration and approvals. A third group such as `approver` appears only when someone who enters a thing must not be the person who releases it. `platform.admin` satisfies every check for a person. The first user of a hosted instance holds it, and it is what lets someone install modules. ## Users and agents A person's groups are set when the user is created (`obop admin create NAME --groups a,b`, or `--all-groups`). An agent token is created with a list of scopes, which are groups, and it can only narrow its owner's groups: asking for a group the owner does not hold is refused. An agent cannot create tokens itself. An unknown command, a hidden command and a forbidden command all answer `404 not found`, so a caller cannot probe which commands exist. ## Limits There are no row-level permissions yet. Access is by model and field group only, so everyone who can read a model reads every record in it. A `visibility` field in an app such as Knowledge is a label, not a rule. See [Create an agent token](/docs/agents/tokens/) and [Safety best practices](/docs/agents/safety/). Source: https://obop-platform.sliplane.app/docs/concepts/permission-groups/ --- # Workflows and approvals ## Workflows are actions A workflow is a set of **actions** on a model. An action moves one `select` field, usually `state` or `stage`, from one option to another: `lead.qualify` moves `stage` from `new` to `qualified`. An action may name a stricter group than the model's write group and may require approval. The field an action moves becomes **governed**. You cannot set it freely with `create` or `update`: - On `create`, leave it out (it starts at the first option) or send exactly the first option. - On `update`, changing it is accepted only as an implicit run of a declared action, and only if you hold that action's group. The web app's board drag and status bar rely on this, so they succeed exactly when the person could have pressed the matching button. - A move with no matching action is refused with a message that lists where the record can go. ## Approval An action can be marked `approval = true`. Mark anything irreversible or money-moving: posting an invoice, paying, refunding, sending to customers, publishing, closing a period. | Who runs it | What happens | | -------------------------------- | ------------------------------------------------------------------------------------------------------- | | A person with the action's group | It runs at once. | | An agent | Nothing changes. The server answers with a **plan id** (`202` over REST, `approval_required` over MCP). | | A person approving the plan | The action runs, as that person. | Deleting a record is also only proposed by agents. A proposal is checked again when it is approved, because the record may have changed in between. ## Why approval is separate from permission Permission says what an agent may try. Approval says what it may do without a person. An agent can hold the permission to post invoices and still only propose each posting, because the steps that cannot be undone are the ones where a human should decide. ## What actions cannot do An action changes one field. It does not compute anything, create records, send email or call other systems. Apps that need those wait for the planned expression and WebAssembly tiers. See [The approval loop](/docs/agents/approvals/) for the exact calls. Source: https://obop-platform.sliplane.app/docs/concepts/workflows-and-approvals/ --- # Getting started ## Option 1: the hosted beta The hosted service gives each business its own instance with its own database. It is free during the beta. 1. **Sign up.** Open [the sign-up app](/app/), enter your email and a password, and choose _Create account_. 2. **Create an instance.** Give it a name of 3 to 30 characters. The address is made from the name. Setting up takes a short while; the page shows the progress. 3. **Sign in.** Follow the link to your instance and sign in with the same email and password. Your sign-up account becomes the first administrator of the instance. 4. **Install an app.** Open _Customize_, then _Marketplace_, and install an app such as Helpdesk or Projects. Installing and removing apps needs the administrator group. The five built-in apps (CRM, Sales, Invoicing, Products and Contacts) are already there. 5. **Create records.** Open an app, choose _New_ and fill in the form. Lists, boards and the side panel are generated from the app's description. 6. **Invite an agent.** There is no screen for agent tokens yet. Sign in over the REST API and [create an agent token](/docs/agents/tokens/), then [connect over MCP](/docs/agents/mcp/) or use [REST](/docs/agents/rest/). ## Option 2: self-host with Docker and PostgreSQL You need Docker. The repository includes a Compose file that starts PostgreSQL and obop together, published on localhost only: ```bash git clone https://github.com/SpaceCorps/obop.git cd obop docker compose up --build ``` The first build compiles the Rust workspace and takes a while. When it is up, open `http://127.0.0.1:8080`. The Compose file starts obop with demo data (`--demo`) and no users, so create the first administrator: ```bash docker compose exec obop obop admin create alice --all-groups ``` obop reads the password from standard input, so you are prompted for it (at least 12 characters). Sign in as `alice`. To run the image against your own PostgreSQL instead, set `OBOP_DATABASE_URL`: ```bash docker build -t obop . docker run -d --name obop -p 8080:8080 \ -e OBOP_DATABASE_URL=postgres://user:password@host:5432/obop \ obop ``` The image runs one instance by default. Its command is `obop serve --addr 0.0.0.0:8080 --static /srv/backoffice`. See [Self-hosting and operations](/docs/reference/self-hosting/) for the control plane, backups and what is missing. ## Option 3: local development with SQLite You need the Rust toolchain from `rust-toolchain.toml`, pnpm and Vite+ (`vp`). SQLite is the default storage, so no database server is needed. ```bash git clone https://github.com/SpaceCorps/obop.git cd obop cargo build vp install (cd web/apps/backoffice && vp build) echo "a password of 12+ characters" | cargo run -- admin create alice --all-groups cargo run -- serve --static web/apps/backoffice/dist ``` Open `http://127.0.0.1:8080` and sign in as `alice`. The data is in `obop.db` in the current directory; pass `--db sqlite:///path/to/file.db` to use another file. A new, empty SQLite database is filled with fake demo data automatically (`--demo` forces it). `--dev-auth` swaps real sign-in for fixed tokens named `dev-admin`, `dev-sales` and `dev-agent`. It refuses to bind to anything but localhost. ## What next - Learn the building blocks in [Concepts](/docs/concepts/). - Browse the [apps](/apps/) and install the ones you need from _Customize_, or from the command line with `obop modules install-from helpdesk`. - Set up an agent: [Agents overview](/docs/agents/). - Write your own app: [module manifest reference](/docs/reference/module-manifest/). Source: https://obop-platform.sliplane.app/docs/getting-started/ --- # Documentation obop is an open-source business platform. One program serves the web app, a REST API, an MCP server and a command line from the same definitions, on one data model. ## Where to start - **New to obop?** Read [Getting started](/docs/getting-started/). It covers the hosted beta, self-hosting with Docker and PostgreSQL, and local development with SQLite. - **Want an AI agent to work with obop?** Start with [Agents](/docs/agents/), then [create an agent token](/docs/agents/tokens/) and [connect over MCP](/docs/agents/mcp/). - **Building your own app?** The [module manifest reference](/docs/reference/module-manifest/) explains how to describe models, fields, workflows and permissions in one `obop.toml` file. - **Looking something up?** See the [CLI](/docs/reference/cli/), [REST API](/docs/reference/rest-api/), [environment variables](/docs/reference/environment/) and the [FAQ](/docs/reference/faq/). ## How the documentation is organised | Section | What it answers | | ---------- | ------------------------------------------------------------------------ | | Start here | How do I get an instance and use it? | | Concepts | What are apps, data classes, permission groups, workflows and instances? | | Agents | How do I set agents up safely, and what can they do? | | Reference | Exact commands, endpoints, settings and limits. | ## Status obop is in beta. Where a feature is not built yet, the page says so plainly. The [roadmap](/roadmap/) lists what exists and what does not, and the [changelog](/changelog/) lists what changed. Every page here is also available as plain Markdown: add `.md` instead of the trailing slash, or read the whole documentation in one file at [llms-full.txt](/llms-full.txt). Source: https://obop-platform.sliplane.app/docs/ --- # CLI reference The `obop` binary has no subcommand help beyond one usage page (`obop --help`). This page restates it, with what each command does. ```text obop [options] ``` ## Global options | Option | Meaning | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `--db URL` | Where the data lives. `sqlite://obop.db` (default), `sqlite:///absolute/path.db`, `postgres://user:pass@host/db`, or `memory:` (gone when the process ends). Also read from `OBOP_DATABASE_URL`. | | `--demo` | Add fake demo data. It is added automatically when a SQLite database is empty. | | `--dev-auth` | Use fixed tokens `dev-admin`, `dev-sales` and `dev-agent` instead of real sign-in. Refuses to bind to anything but localhost. For development only. | | `--version` | Print the version. | ## Serving | Command | What it does | | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `obop serve [--addr 127.0.0.1:8080] [--static DIR]` | Runs one instance: the REST API under `/api/` and, with `--static`, a directory of static files (the back office build). Unknown paths fall back to `DIR/index.html`. | | `obop platform serve [--addr A] [--static DIR] [--provisioner local\|sliplane\|fake] [--instance-static DIR]` | Runs the control plane: sign-up, sign-in and instance management, plus the static website at `/`. `--instance-static` is the back office build that local instances serve. | | `obop mcp [--token TOKEN]` | MCP server on standard input and output for one token. Token from `--token` or `OBOP_TOKEN`. See [Connect over MCP](/docs/agents/mcp/). | ## Calling commands | Command | What it does | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `obop commands [--token TOKEN]` | Lists the commands this token may use, with a note on those that need approval for agents. | | `obop call [--token TOKEN] [key=value ...]` | Runs one command and prints the result as JSON. Values are converted by field type. Structured values such as `filter` and `order` are JSON text. | ## Users and tokens | Command | What it does | | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `obop admin create USER [--groups a,b \| --all-groups]` | Creates a user. The password is read from standard input and must have at least 12 characters. `--all-groups` grants `platform.admin` and every built-in group. | | `obop agent-token USER --scopes a,b [--label TEXT]` | Issues an agent token for an existing user. Shown once. Scopes must be groups the user holds. | Example: ```bash echo "a password of 12+ characters" | obop admin create alice --groups crm.user,sales.user obop agent-token alice --scopes crm.user --label triage-bot ``` ## Modules | Command | What it does | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `obop modules check [FILE...]` | Runs the install gate. Without files, over the built-in modules. With files, over each `obop.toml`, without a database. Prints `ok FILE` or `FAIL FILE: reason` and exits non-zero on any failure. | | `obop modules list` | Lists installed modules with their source (built in, marketplace or upload). | | `obop modules market` | Lists the marketplace: the bundled catalog plus any extra catalog from `OBOP_MARKETPLACE_URL`. | | `obop modules install-from ID` | Installs a package from the marketplace. | | `obop modules install FILE` | Installs or upgrades from a local `obop.toml`. The version must be higher to upgrade. | | `obop modules uninstall ID` | Removes the package's commands and screens. Its data stays in the database. Refused while another installed module depends on it. | These are operator tools: they write to the database directly. Inside an instance, administrators do the same from _Customize_. ## Exit status `0` on success. Anything else prints a line starting with `error:` on standard error. A command that needs approval is reported as `error: ApprovalRequired { plan_id: ... }`. ## Defaults worth knowing - The instance listens on `127.0.0.1:8080` unless you pass `--addr`. - Sign-in sessions last 12 hours. Five wrong passwords lock a username for a minute. - The CLI prints `database: sqlite` (or the backend name) on standard error at start. Source: https://obop-platform.sliplane.app/docs/reference/cli/ --- # Environment variables obop reads a small number of environment variables. Command line flags win over variables. ## Instance and CLI | Variable | Meaning | Default | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | | `OBOP_DATABASE_URL` | Where the data lives: `sqlite://obop.db`, `sqlite:///abs/path.db`, `postgres://user:pass@host/db` or `memory:`. Same as `--db`. | `sqlite://obop.db` | | `OBOP_TOKEN` | Bearer token for `obop mcp`, `obop call` and `obop commands`. Same as `--token`. | none | | `OBOP_MARKETPLACE_URL` | An extra marketplace: a host serving `index.json` and `packages/.toml`. Shown after the bundled catalog. | none | | `OBOP_PUBLIC_URL` | External URL of an instance behind a proxy (`https://acme.example.com`), used in the MCP discovery documents and in the `401` challenge of `/mcp`. | from the request | | `OBOP_ALLOWED_ORIGINS` | Comma-separated browser origins allowed to call `/mcp` (DNS-rebinding protection). Clients that are not browsers send no origin. | none | | `OBOP_MCP_RATE_PER_MINUTE` | Requests per token per minute on `/mcp`; `0` turns the limit off. | 600 | ## Control plane Used by `obop platform serve`. | Variable | Meaning | | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `OBOP_DATA_DIR` | Where the `local` provisioner keeps instance data. Default `obop-instances`. | | `OBOP_IMAGE` or `OBOP_REPO` (with `OBOP_BRANCH`, default `main`) | Where instance services come from with the `sliplane` provisioner. A repository means Sliplane builds each instance; an image is faster. | | `OBOP_MARKETPLACE_URL` | Optional extra catalog for every instance. | ## Sliplane provisioner `--provisioner sliplane` needs: | Variable | Meaning | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `SLIPLANE_ACCOUNT` | Account name the bundled Sliplane CLI is registered under. | | `SLIPLANE_API_KEY` | A Sliplane API key with write access. The container entrypoint registers it with the CLI and removes it from the environment. Set it as a secret. | | `OBOP_SLIPLANE_PROJECT`, `OBOP_SLIPLANE_SERVER` | Where instance services go. | | `OBOP_CELL_ADMIN_URL` | `postgres://` superuser URL of the shared PostgreSQL server, as the control plane reaches it. | | `OBOP_CELL_INSTANCE_HOST` | `host:port` that instances use to reach that server, for example `obop-postgres.internal:5432`. | ## Build time (the website) | Variable | Meaning | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `OBOP_SITE_URL` | Public origin used for canonical links, the sitemap and social cards when building this website. Default: `https://obop-platform.sliplane.app`. | ## Tests `OBOP_TEST_POSTGRES_URL` adds PostgreSQL to `cargo test`. `OBOP_BIN` points the end-to-end suite at a built binary. Source: https://obop-platform.sliplane.app/docs/reference/environment/ --- # FAQ ## General ### What is obop? An open-source business platform with CRM, sales, invoicing, products and contacts built in, 22 more apps in a marketplace, and a REST API, MCP server and command line generated from the same definitions. See [Platform](/platform/). ### Is it ready for production? Not yet. obop is in beta. Read the [roadmap](/roadmap/) and the limits on [Security and privacy](/docs/reference/security/) before you rely on it. ### What does it cost? Self-hosting is free. Hosted plans are planned at €0, €49, €249 and from €899 a month per organisation. Nothing is charged during the beta. See [Pricing](/pricing/). ### Which licence? The code is on GitHub. The final licence is not decided; the project proposes the AGPL for the core. Check the repository for current terms. ### What is it written in? Rust for the backend and Vite+ with React for the web apps. This website is built with VitePress. ## Using obop ### How do I add an app? Open _Customize_, then _Marketplace_, and install one. From the command line: `obop modules install-from helpdesk`. You can also upload your own `obop.toml`. See [Apps and modules](/docs/concepts/apps-and-modules/). ### Can I write my own app? Yes, as one text file. There is no code tier yet, so apps cannot compute totals, generate numbers or call other systems. See the [module manifest reference](/docs/reference/module-manifest/). ### Why can't my app calculate totals? Packages are declarative on purpose, so administrators can install them safely. Expressions and WebAssembly extensions are planned. ### What happens to my data if I uninstall an app? The commands and screens go away and the data stays in the database. Installing the app again brings the records back. ### Can I change an installed app? Raise its `version` and install the file again. There are no migrations: add optional fields rather than renaming. ## Agents ### Can an agent see my customers' emails? Not in clear. It sees placeholder tokens such as `EMAIL_1`. See [Masking tokens](/docs/agents/masking/). ### Can an agent post an invoice or delete a record? It can propose either; a person approves. See [The approval loop](/docs/agents/approvals/). ### Does obop work with Claude, ChatGPT, Cursor or other tools? obop has an MCP server over stdio and a REST API. Any MCP client that can start a local process works; see [Connect over MCP](/docs/agents/mcp/). There is no remote MCP endpoint yet, so hosted instances are reached over REST. ### How do I stop an agent? There is no revoke command yet. Remove the owning user, or stop giving the token to the agent. See [Safety](/docs/agents/safety/). ## Hosting and data ### Where is my data? Hosted: in a database that belongs to your instance, on PostgreSQL. Self-hosted: wherever you run it. ### How do I back up? Use your database's tools (`pg_dump`, snapshots) or copy the SQLite file while the server is stopped. obop has no built-in backup yet. ### Can I export everything? Your database is yours. A built-in full export is planned but not built. ### Which languages is the website in? English, German, French, Polish, Finnish, Swedish and Spanish. The application itself is in English for now. The translations on this site are machine-drafted and are being reviewed by native speakers. ## Contributing and contact See [About](/about/), [Contact](/contact/) and the [community](/about/community/). Source: https://obop-platform.sliplane.app/docs/reference/faq/ --- # Module manifest reference Audience: a developer or a business analyst who wants to add an app to obop without writing Rust. Everything here is checked against the code (`crates/kernel/obop-pkg/src/manifest.rs`, `installer.rs`, `marketplace.rs` and the command layer in `obop-commands`). The tutorial module is a real file, `docs/modules/assets.toml`, and the end-to-end test `the_tutorial_module_and_its_documented_errors_are_real` in `crates/obop/src/main.rs` installs it and triggers every error quoted in section 3.5. ## 1. What a module is A module (also called a package) is one text file, `obop.toml`. It declares **models** (record types), their **fields** (each with a kind, a data class, a description and a permission group) and **actions** (state transitions). Installing it registers the models with the one command dispatcher, so the same module is immediately available as REST endpoints, MCP tools for agents, CLI commands and backoffice screens (list, kanban, form, status bar, buttons), with masking, permission checks and agent approval applied for free (D29, D41, D45, D46). ### Tiers | Tier | What it can contain | Status | |---|---|---| | X1 declarative | Models, typed fields, data classes, permission groups, state-transition actions. No code. | Built (this document; D45, D47) | | X2 CEL | Expressions stored in data: computed fields, validation, conditions, modifiers, policies. | Not built (D11) | | X3 Wasm | Sandboxed components for logic that expressions cannot express (connectors, side effects). | Not built (D11) | X1 packages cannot run anything, so an administrator can install one from an upload at runtime. When X2 and X3 arrive a package will gain sections for expressions and components; the X1 sections described here stay valid. Behaviour that needs code (computed totals, sequences, side effects, validation across fields) waits for those tiers (D45 consequences). ### What ships Built-in modules (Rust crates, not removable): `base` (model `party`), `product` (`product`), `crm`, `sales` (`sale_order`), `invoicing` (`invoice`). Bundled packages in `packages/*.toml` (installed from the Customize app or `obop modules install-from ID`): | Package | Needs | What it holds | |---|---|---| | `accounting` | base | Chart of accounts, journals, taxes (basis points), fiscal years and periods, journal entries and lines, bank statement lines. Posting and closing need approval and `accounting.approver` | | `appointments`, `documents`, `expenses`, `fleet`, `helpdesk`, `manufacturing`, `projects`, `purchase`, `inventory`, `subscriptions`, `timesheets`, `hr` | varies | Services, supply chain and people apps | | `planning` | hr | Roles, shift templates, shifts (open, published, confirmed) | | `field_service` | base, product, hr | Jobs with site, technician, parts used, signature flag | | `pos` | base, product | Point-of-sale config, cash-controlled sessions, orders, lines, payments; refunds and session close need approval | | `ecommerce` | base, product | Categories, listings and translations, shipping methods, discount codes, web orders and lines; public shop and checkout when the site is enabled; refunds need approval | | `website` | none | Pages, menu items, blog posts, form submissions and translations; served publicly when the site is enabled (docs/STOREFRONT.md) | | `email_marketing` | none | Lists, contacts with consent, campaigns with statistics; sending needs approval | | `sign` | none | Templates and signature requests | | `dashboards` | none | Dashboards and widgets as data (schema in section 9) | | `knowledge` | none | Article tree | ## 2. Tutorial: an asset register We build `assets`: categories of equipment and the individual assets, with a holder, a life cycle and a retire step that needs approval. The full file is `docs/modules/assets.toml`. It is shown in pieces; the pieces concatenated are the file. ### 2.1 The `[module]` table ```toml [module] id = "assets" version = "0.1.0" name = "Assets" description = "A register of company equipment: categories, individual assets with a holder, and a stock, assigned, in repair, retired life cycle. Purchase prices are visible to managers only; retiring an asset needs a human approval." author = "Example Ltd" depends = ["base"] ``` `id` names the module and prefixes everything it owns: every model must be called `assets` or `assets_`, and by convention permission groups are `assets.`. `version` is three numbers (`0.1.0`); a later install of the same id must have a higher version. `name` is what the app launcher shows. `description` is shown in the marketplace and Installed list, and is required. `depends` lists modules that must already be installed; because `assets_asset` has a ref to `party` (a built-in model of `base`), we depend on `base`. ### 2.2 A simple model ```toml [[model]] name = "assets_category" description = "A kind of asset, for example Laptops or Vehicles" read_group = "assets.user" write_group = "assets.manager" [[model.field]] name = "name" description = "Category name" class = "none" required = true [[model.field]] name = "useful_life_years" description = "How many years assets of this kind are expected to last" class = "none" kind = "number" ``` A model needs a description and two groups: people holding `read_group` can read records (`get`, `list`, `search`); people holding `write_group` can `create`, `update` and `delete`. Every field needs a description and a data class. `class = "none"` is how you say "this is not personal data and not otherwise sensitive"; it must be written out. `required = true` makes the server refuse a create without the field. Fields default to `kind = "text"`. Each model automatically gets six commands, with the same names on REST, MCP and the CLI: `.get`, `.list`, `.search`, `.create`, `.update` and `.delete`. Records get an `id` assigned by the server. `list` and `search` take the optional `filter`, `order`, `limit` and `offset` of section 4.1; `delete` removes a record that no other record refers to and, being destructive, is only *proposed* by an agent. A field that is the target of an action is governed (section 6.1): `create` and `update` cannot set it freely. ### 2.3 The main model: kinds, refs, a field-level group, personal data ```toml [[model]] name = "assets_asset" description = "One piece of equipment the company owns" read_group = "assets.user" write_group = "assets.user" [[model.field]] name = "tag" description = "Inventory tag stuck on the asset, unique by convention" class = "none" required = true [[model.field]] name = "name" description = "What the asset is, for example MacBook Pro 14" class = "none" required = true [[model.field]] name = "category_id" description = "The category of the asset" class = "none" kind = "ref" to = "assets_category" [[model.field]] name = "supplier_id" description = "The contact the asset was bought from" class = "none" kind = "ref" to = "party" [[model.field]] name = "replaces_id" description = "The older asset this one replaced" class = "none" kind = "ref" to = "assets_asset" [[model.field]] name = "purchased_on" description = "Purchase date (YYYY-MM-DD)" class = "none" kind = "date" [[model.field]] name = "purchase_price" description = "Price paid (minor currency units, e.g. cents)" class = "none" kind = "money" group = "assets.manager" [[model.field]] name = "holder" description = "Name of the person who has the asset" class = "internal" personal_data = true [[model.field]] name = "notes" description = "Free-text remarks" class = "none" kind = "longtext" [[model.field]] name = "state" description = "Life cycle: in_stock, assigned, in_repair, retired" class = "none" kind = "select" options = ["in_stock", "assigned", "in_repair", "retired"] required = true ``` Things to notice: - `category_id` refs a model of the same package (`assets_category`); it is checked when the file is parsed. `supplier_id` refs `party`, which belongs to `base`: that is why `depends = ["base"]`, and the installer also checks at install time that `party` is registered. `replaces_id` points at its own model, which is allowed (a ref to itself is optional here; it could not be `required`, because the first record could never be created). - `purchase_price` has `group = "assets.manager"`. A person who holds only `assets.user` does not see this field at all (it is absent from `/api/meta` and from reads). The default group of a field is the model's `read_group`. - `holder` is a person's name, so `personal_data = true` and the class cannot be `none`. `internal` ("non-public business data about people and work") fits. - `state` is the conventional status field. For a `select` called `state` the options are the status order, and the UI draws them as a status bar and kanban columns. ### 2.4 Actions ```toml [[model.action]] name = "assign" description = "Hand the asset to a person" field = "state" from = "in_stock" to = "assigned" [[model.action]] name = "take_back" description = "Take the asset back into stock" field = "state" from = "assigned" to = "in_stock" [[model.action]] name = "send_to_repair" description = "Send an assigned asset to repair" field = "state" from = "assigned" to = "in_repair" [[model.action]] name = "retire" description = "Write the asset off for good (manager only)" field = "state" from = "in_stock" to = "retired" group = "assets.manager" approval = true ``` Actions belong to the `[[model]]` above them (they are written after its fields). `asset.assign` takes `{ "id": ... }`, checks that `state` is `in_stock` and sets it to `assigned`. Called again, it fails: `state must be in_stock to do this, it is "assigned"`. `retire` is restricted to `assets.manager` and has `approval = true`: a human with that group runs it directly, but an AI agent can only *propose* it; a human then approves the proposal (section 6). ### 2.5 Try it ```sh cargo run -- modules install docs/modules/assets.toml --db sqlite:///tmp/x.db # installed assets 0.1.0 (models: assets_category, assets_asset; groups: assets.manager, assets.user) cargo run -- modules list --db sqlite:///tmp/x.db ``` Installing it again fails with `assets 0.1.0 is installed; installing 0.1.0 is not an upgrade`. Edit `version` to `0.2.0` and it upgrades in place. In the backoffice open Customize, Modules, "Upload your own" and pick the file, then find "Assets" in the app launcher. Note that `platform.admin` holds every group, so an admin sees all of it; create a user with `--groups assets.user` to see what an ordinary employee sees. ## 3. Manifest reference The file is TOML, at most **256 KiB**, with at most **50 models** and **200 fields in total** across all models. Unknown keys anywhere are errors (`invalid obop.toml: unknown field ...`). Identifiers (`id`, model, field and action names, and both halves of a group name) are lowercase ASCII letters, digits and `_`, start with a letter, at most 40 characters. ### 3.1 `[module]` (one, required) | Key | Type | Required | Rules | |---|---|---|---| | `id` | string | yes | Identifier. Must not equal a built-in module id. | | `version` | string | yes | `major.minor.patch`, three numbers of at most six digits each. | | `name` | string | yes | Not blank. | | `description` | string | yes | Not blank. | | `author` | string | no | Default empty. Shown in listings. | | `depends` | array of module ids | no | Default `[]`. Each must be installed (built-in or package) when installing; a module that others depend on cannot be uninstalled. | | `network_destinations` | array | no | Must be empty or absent: declarative packages may not reach the network. The key exists so later tiers share the contract. | ### 3.2 `[[model]]` (any number) | Key | Type | Required | Rules | |---|---|---|---| | `name` | string | yes | Identifier equal to the module id or starting with `_`; unique in the file; must not already exist in the system. | | `description` | string | yes | Shown in the gate and the UI. | | `read_group` | string | yes | `module.role`. Needed for `get`, `list`. | | `write_group` | string | yes | `module.role`. Needed for `create`, `update`. | | `field` | array of tables | no | See 3.3. | | `action` | array of tables | no | See 3.4. | `[[model.field]]` and `[[model.action]]` are written after their model, indented by convention. ### 3.3 `[[model.field]]` | Key | Type | Default | Rules | |---|---|---|---| | `name` | string | required | Identifier, unique in the model, never `id`. | | `description` | string | required | Not blank (the gate refuses `field has no description`). | | `class` | string | required | One of `none`, `public_directory`, `internal`, `confidential_hr`, `special_category`, `financial`, `secret`. See section 5. | | `personal_data` | bool | `false` | `true` if the value is about an identifiable person. Not allowed with `class = "none"`. | | `group` | string | the model's `read_group` | `module.role`. Principals without it do not see the field. | | `kind` | string | `text` | `text`, `longtext`, `number`, `money`, `date`, `bool`, `select`, `ref`. | | `options` | array of strings | none | Only for `select` (then required, non-empty, no blanks, no duplicates). | | `to` | string | none | Only for `ref` (then required). Model name. | | `required` | bool | `false` | Must be present and non-null on create; cannot be set to null on update. | ### 3.4 `[[model.action]]` | Key | Type | Default | Rules | |---|---|---|---| | `name` | string | required | Identifier; not `get`, `list`, `search`, `create`, `update` or `delete`; unique within the model (a duplicate is refused). The command id is `.`. | | `description` | string | required | Not blank. | | `field` | string | required | A `select` field declared in the same model; any select field, not only `state` (the UI draws `state` as the status bar and kanban, other selects are governed the same way). That field becomes *governed* (section 6.1). | | `from` | string | none | The value the field must have now; one of the options. Without `from` the action works from any value; the bundled packages always set it. Must differ from `to`. | | `to` | string | required | The value to set; one of the options. | | `group` | string | the model's `write_group` | `module.role`. | | `approval` | bool | `false` | Agents may only propose it (D18). | ### 3.5 Validation errors you can hit Parse and gate errors (from `obop modules install`, the Customize upload or `module.install`), and how to fix them: | Message contains | Cause and fix | |---|---| | `invalid obop.toml: ...` | Not valid TOML, a missing required key, a value of the wrong type (for example `required = "yes"`), or an unknown key. Read the line and column in the message. | | `manifest is larger than 256 KiB` | Split the module. | | `module id "X" must be lowercase letters, digits and _, starting with a letter` | Fix the id. | | `version must look like 1.2.3` | Three dot-separated numbers. | | `module needs a name and a description` | Fill them in. | | `declarative packages run no code and may not declare network destinations` | Remove `network_destinations`. | | `package is too large` | More than 50 models or 200 fields. | | `model "X" must be "" or start with "_"` | Rename the model so it carries the module id as prefix. | | `model X is declared twice` | Duplicate model name. | | `permission group "X" must look like module.role` | Two identifiers joined by a dot, for example `assets.user`. | | `M.F: bad or repeated field name` | Not an identifier, named `id`, or declared twice. | | `M.F: unknown data class "X"` | Use one of the seven classes. | | `M.F: unknown kind "X" (use text, longtext, number, money, date, bool, select or ref)` | Floats do not exist; use `number` (integer) or `money` (integer minor units). | | `M.F: options are only valid for kind "select"` / `` `to` is only valid for kind "ref" `` | Remove the stray key or fix the kind. | | `M.F: a select needs options` / `select options must be non-empty and unique` | Give at least one option; no blanks or duplicates. | | `M.F: a ref needs `to`, the target model` | Add `to`. | | `M.F: `to` "X" is not a model name` | Target must be an identifier. | | `M.F: `to` names "X", which this package does not declare` | A ref to a model that starts with your own id must be one of your models. | | `M: bad action name "X"` | Not an identifier, or one of `get`, `list`, `search`, `create`, `update`, `delete`. | | `M: action "X" is declared twice` | Two actions of one model share a name; use distinct names (two ways to one state need two names). | | `M.A: acts on undeclared field "X"` | `field` must name a field of the same model. | | `M.A: acts on F, which is a text field; an action moves a select field between its options` | Make the field `kind = "select"` with `options`. | | `M.A: "V" is not one of the options of F (...)` | `from` or `to` is not in the select's options. | | `M.A: `from` and `to` are both "V"; an action must change the state` | An action that changes nothing is refused. | | `...: personal data cannot be classed `none`` | The D41 gate: choose a real class for a field with `personal_data = true`. | | `...: field has no description` / `command has no description` / `model has no description` | Descriptions are mandatory (they are what agents and the UI show). | Install-time errors: | Message | Cause and fix | |---|---| | `X needs module Y, which is not installed` | Install the dependency first (or fix `depends`). | | `X is a built-in module` | Your id clashes with `base`, `product`, `crm`, `sales`, `invoicing` or `platform`. | | `X 0.1.0 is installed; installing 0.1.0 is not an upgrade` | Raise `version`. | | `model X already exists` | Another module owns a model of that name. | | `M.F refers to model T, which is not installed` | A `ref` into another package or built-in: install it and add it to `depends`. | | `another installed module depends on X` | On uninstall: remove the dependents first. | ### 3.6 `[model.public]`: what visitors may see and send (D50) Optional, one per model, written right after the `[[model]]` keys. Without it nothing of the model is public. Needs the instance to run with `--public-site` (docs/STOREFRONT.md); the declaration itself is checked on every install. | Key | Type | Rules | |---|---|---| | `read` | bool | Anonymous visitors may read records. Needs `published_field` and `fields`. | | `published_field` | string | A `bool` field (public when true) or a `select` (public when it equals `published_value`). Records that are not public do not exist for visitors. | | `published_value` | string | Only with a `select` `published_field`: the public option, for example `published`. Using the workflow state keeps the approval on `publish` in charge of what is public. | | `gone_value` | string | Optional, a `select` option meaning "was public, retired" (for example `archived`): answered with 410 instead of 404. | | `fields` | array | The only fields a public read returns. Each must be class `none` or `public_directory`, not personal data, and not restricted to a stricter `group`. A `ref` is shown as the public fields of the target only if the target model is public-read and that record is published. | | `order_by`, `order_dir` | string | Sort of public lists; `asc` (default) or `desc`. | | `submit` | bool | Visitors may create records. Needs `submit_fields` and `rate_limit_per_hour`. | | `submit_fields` | array | The only fields a visitor may set. Class may be `none`, `public_directory` or `internal` (an order email is fine); never a governed state field. They are never readable publicly unless also in `fields`. `required` is still enforced. A `ref` must point at a published record of a public-read model. | | `rate_limit_per_hour` | number | Submissions per source and hour (50 times that across all sources). | | `receipt_token_field` | string | A `text` field of class `secret` holding an unguessable token; whoever holds it may read that one record back through `receipt_fields` (an order confirmation). Nobody can read the token itself. | | `receipt_parent` | string | On a child model: a `ref` to the model that has a token; the record is shown with its parent (order lines). | | `receipt_fields` | array | What the receipt shows; may include personal data the submitter gave, never `confidential_hr`, `special_category`, `financial` or `secret`. | Errors: `M.F: only fields of class none or public_directory can be public`, `M.F: personal data can never be public`, `M.public: read = true needs published_field, so only published records are public`, `M.F: a governed state field cannot be submitted; it starts at its first option`, `M.F: a public form cannot collect class C data`. ## 4. Field kinds and their JSON Values travel as JSON on REST and MCP, and as `key=value` text on the CLI (which converts `number`, `money` and `bool` using the metadata). The server validates every create and update (HTTP 400, `. must be `); existing records are not re-validated. | Kind | JSON | Notes and example | |---|---|---| | `text` | string | One line. `"MBP-0042"` | | `longtext` | string | Multi-line (the UI shows a text area). `"line 1\nline 2"` | | `number` | integer | No floats. Use it for counts, minutes, basis points. `1020` | | `money` | integer | Minor currency units, never a float or a numeric string. 12.34 EUR is `1234`. The package does not store a currency unless you add a field for it. | | `date` | string `YYYY-MM-DD` | Must be a real calendar date (leap years honoured). `"2026-01-31"` | | `bool` | `true`/`false` | | | `select` | string | One of `options`. `"in_stock"` | | `ref` | string | The id of an existing record of model `to`; a dangling id is refused. `"9f3c..."` | ### 4.1 `list`, `search` and `delete` `.list` and `.search` take the same optional input; `list` returns a bare array of rows (as before, with no input it returns every record in insertion order), `search` returns `{"rows": [...], "total": N}` where `total` counts the matches *before* `limit` and `offset`. ```json { "filter": [ {"field": "stage", "op": "in", "value": ["new", "qualified"]}, {"field": "expected_revenue_minor", "op": "gte", "value": 100000} ], "order": [ {"field": "expected_revenue_minor", "dir": "desc"}, {"field": "title"} ], "limit": 50, "offset": 100 } ``` - `filter`: conditions `{field, op, value}`, ANDed (at most 50). Ops: `eq`, `ne`, `contains` (text and longtext only, case-insensitive substring, no wildcards), `in` (an array of at most 1000 values), `gt`, `gte`, `lt`, `lte` (not for `bool` or `ref`), `is_null` (`value` true, the default, or false). `ne` also matches records where the field is empty; the other ops never match an empty value. Values must have the field's kind (`number` and `money` as integers, `date` as `YYYY-MM-DD`, a `select` value must be an option, a `ref` is an id) and comparisons are typed: numbers numerically, dates chronologically, selects in option order, text bytewise (`contains` and `order` ignore case). `id` can be used as a field. - `order`: up to 5 `{field, dir}` with `dir` `asc` (default) or `desc`; empty values sort last in both directions; ties keep insertion order. - `limit`: 1 to 5000 (default: no limit); `offset`: 0 or more. - Unknown keys, ops or malformed values are an error (HTTP 400, for example `unknown op "like" (use eq, ne, contains, in, gt, gte, lt, lte, is_null)`). - Filtering and ordering happen before masking and withholding, so they are restricted to fields the caller can read in clear: a field the caller's groups do not include, a secret field, and for an agent any personal-data or sensitive field (which it sees only as a token or not at all) are refused with exactly the error of a field that does not exist, `unknown field .`. A search can therefore not be used to guess values the caller may not see. `.delete` takes `{"id": ...}` and returns `{"id": ...}`. It needs the model's `write_group`; an agent only gets an approval plan. It is refused while another record has a `ref` to the record: `cannot delete party P: still referenced by accounting_move (3): id1, id2, id3; sale_order (1): id4` (the first five ids of each model). Conventions the bundled packages follow: refs are named `_id`; time of day is a `number` of minutes since midnight (540 is 09:00) next to a `date`; rates are `number`s in basis points (2000 is 20.00 percent); free-text codes such as invoice numbers are `text` because packages cannot generate sequences; yes/no with more than two states is a `select`. ## 5. Data classes, personal data and permission groups ### Which class A data class tags a field for masking, export and retention (CONVENTIONS section 5, D40, D41). Pick the strictest that fits; a value from which a protected value can be recomputed carries the strictest class of its inputs. | Class | Use for | Examples | |---|---|---| | `none` | Not personal and not otherwise sensitive. Must be written explicitly. | Product code, state, quantity, own price | | `public_directory` | Directory data every internal user may see. | Employee name, work email, job title, blog author | | `internal` | Business data about people and work. The default choice for a person's name, email or address in operations. | Ticket requester email, holder, technician, signer name, site address | | `confidential_hr` | Personal employment facts that are not money. | Contract type, home address, national id, birth date | | `special_category` | GDPR Art. 9: health, union, work permit, biometrics. | Sick-leave flag | | `financial` | Money owed to, paid to or held for a named person or counterparty; bank accounts; payment references. | Salary, IBAN, expense amount, bank statement lines | | `secret` | Credentials and keys; write-only. | API keys, webhook secrets | `confidential_hr`, `special_category` and `financial` are the sensitive classes: they are withheld from agents and non-local AI routes by default and get stricter handling. A class is not a permission group; use `group` on the field to restrict who sees it. `personal_data = true` marks a field as about an identifiable person; it drives access and erasure requests (D33). A personal-data field may never be `none` (the gate refuses it). A field can be sensitive without being personal data (a company's bank balance is `financial`, not personal). ### Groups A group is just a name `module.role`; it exists as soon as something refers to it. Conventions: `.user` (day-to-day), `.manager` (configuration and approvals); add a third group (`approver`, `finance`, `special_reader`) only when a separation of duty is real: someone who enters a thing must not be the person who releases it. The administrator group `platform.admin` satisfies every check for a human; an agent token only has the concrete groups listed in its scopes, and can only narrow its owner's groups. Three places take a group: 1. Model `read_group` / `write_group`: read (`get`, `list`, `search`) and write (`create`, `update`, `delete`). 2. Field `group` (default: the model's read group): who sees the field. A field the principal cannot read is not described to it in `/api/meta`. 3. Action `group` (default: the model's write group): who may run the step. This is how you require a stricter group for posting, paying, closing. ## 6. Workflow actions and approvals An action is the only custom behaviour X1 offers: a guarded assignment `field := to` valid when `field == from`. Chain actions on one `state` select to build a workflow; you may declare several actions with different `from` values (for example `refund` from `paid` and `refund_delivered` from `delivered`), and several actions can lead to the same state. Action names must be unique per model, so use distinct names for the two refunds. What the dispatcher does with `approval = true`: - A **human** who holds the action's group runs it directly and it takes effect at once. - An **agent** (a principal made by `agent-token`, or an MCP client) that calls it gets `ApprovalRequired { plan_id }`; nothing changes. A human reviews and approves the plan (`POST /api/approve/{plan_id}`, or the approval prompt in the UI), and only then the action runs, as the human. - Agents may run actions without `approval` directly, within their scopes. Mark `approval = true` on anything irreversible or money-moving: posting to a ledger, paying, refunding, sending to customers, publishing publicly, closing a period, retiring an asset. Reversible bookkeeping steps (assign, schedule, handle) normally do not need it. What actions cannot do: set more than one field, compute anything, create records, notify or call out. When an action runs it also checks that the record has every `required` field set (the error names the field, for example `helpdesk_ticket.subject is required and must be set before state can change to "in_progress"`). ### 6.1 Governed fields: actions are the only way to move a state (D48) A field that is the `field` of at least one action is *governed*. The generic `create` and `update` cannot set it freely, so the stricter group and the `approval` flag of an action cannot be bypassed: - **create**: leave a governed field out (it becomes the first option of its select) or send exactly the first option. Anything else is refused: `helpdesk_ticket.state starts as "open" when a record is created; it moves on through the model's actions`. A value that is not an option at all gets the ordinary `must be one of: ...`. - **update**: setting a governed field to the value it already has is not a change. Changing it from `a` to `b` is accepted only as an implicit run of a declared action: there must be an action on that field with `to = b` and `from` unset or `= a`, and the caller must hold *that action's* group. If the action has `approval = true`, an agent's update returns the usual approval plan (`202`, `plan_id`), and a human who holds the action's group approves it; the action then really runs (side effects such as the invoice number included). A change with no such action is refused and says where the record can go: `helpdesk_ticket.state cannot change from "open" to "closed"; from "open" it can only go to: in_progress`, or `... no transition leaves "closed"`. A caller without the group is told which action and group: `... moving from "draft" to "posted" is the post action, which needs the accounting.approver group`. - Other fields of the same `update` are validated as before, all before anything is written, so a mixed update applies completely or not at all. The record's required fields must be set for a transition to run. - When several actions lead to the same state the caller uses one it may run (an exact `from` before an open one). This is how the UI's kanban drag and status-bar clicks keep working: they send `update` with the new state, and succeed exactly when the person could have pressed the corresponding action button. - A proposal is checked again when it is approved, because the record may have changed since. - Data written outside the commands (seeds, imports straight into the store) is not checked. The built-in modules follow the same rule: `lead.stage` (actions `qualify`, `mark_won`, `mark_lost`, `reopen`), `sale_order.state` (`confirm`, `cancel`, `reset_to_draft`) and `invoice.state` (`post`, needing `invoicing.manager` and approval, and `cancel`). ## 7. Refs, `depends`, installing, upgrading, removing - A `ref` to a model in your own package is verified when the file is parsed. A ref to any other model (built-in `party`, `product`, `sale_order`, `invoice`; or another package's) needs that model to exist at install time and the module that owns it in `depends`. Built-in module ids to use: `base` (party), `product`, `sales` (sale_order), `invoicing` (invoice), `crm`. Packages: their own id (`hr` for `hr_employee`). - **Install** validates the file, runs the gate, checks `depends`, refuses name collisions, registers the models, and stores the manifest. Order matters only through `depends`: install dependencies first. - **Upgrade**: install a file with the same `id` and a **higher** `version` (compared as three numbers). The module is unregistered and registered again from the new file. The stored records are not touched. There are **no migrations**: if you rename or remove a field or model, old records keep their old data in the store but the new definition no longer exposes it; if you add a `required` field, old records are not re-validated. Prefer adding optional fields. - **Uninstall** (`obop modules uninstall ID`, Customize, or `module.uninstall`; an administrator only) removes the commands and the models from the app; the **data stays** in the database, and installing the module again brings every record back. A module cannot be uninstalled while another installed package lists it in `depends`. - Install, upgrade and uninstall are `platform.admin` commands that agents may only propose. ## 8. Testing and publishing ### Test a package - `cargo run -- modules install FILE --db sqlite:///path/x.db` runs exactly the checks of an upload: parse, validation, the D41 gate, dependencies, refs. It prints the models and groups it created, or the error. Use a throwaway database; install dependencies first with `modules install-from ID` (for bundled packages). - `cargo run -- modules check FILE...` needs no database: it parses each file, runs the D41 gate, and applies the install rules that need no database (the id is not a built-in module, every `depends` entry is a built-in module or a bundled package, every `ref` to a model outside the package points at a model of the dependencies). It prints `ok FILE`, or `FAIL FILE: ` for each problem, and exits non-zero if any file fails. `cargo run -- modules check` without files runs the gate over the linked built-in modules. - Customize app, **Module builder**: define models, fields (name, description, kind, options, target), and actions in a form; it writes the `obop.toml` for you and installs it. Customize, **Upload your own** accepts any `.toml` file and shows the error text on failure. - To exercise a package like the bundled ones, copy the pattern of the `bundled_packages` tests in `crates/obop/src/main.rs`: install through the real installer, create a record per model from the metadata, run every action in and out of order, check agents are only given a proposal for approval actions. - Try as an ordinary user: `echo "a-long-password" | cargo run -- admin create alice --groups assets.user --db sqlite:///path/x.db`, then log in and see which fields and buttons appear. ### Publish The marketplace is a static host. Serve over HTTP or HTTPS: ``` index.json [{"id":"assets","name":"Assets","version":"0.1.0","description":"...","author":"Example Ltd"}, ...] packages/assets.toml the obop.toml of each package listed ``` `index.json` is an array of objects with `id`, `name`, `version`, `description` and optional `author`. Package ids may contain only lowercase letters, digits and `_` (anything else is refused before a request is made). Point an instance at it with the environment variable `OBOP_MARKETPLACE_URL=https://host/path` (no trailing slash needed). The instance shows the bundled catalogue first and your host after it; if the same id exists in both, the bundled one wins. Every download is validated exactly like an upload, and responses are limited to 1 MiB. The bundled catalogue itself is `packages/*.toml`, compiled into the binary by `crates/kernel/obop-pkg/src/marketplace.rs`. Package signing and paid listings are not built (D45). ## 9. Packages as data: the dashboards widget schema The `dashboards` package stores dashboards as records so a UI can render them later; it never runs a query itself and does not check that the model or fields named in a widget exist. `dashboards_dashboard`: `title` (required), `owner` (required, name; personal data), `description`, `shared` (bool), `position` (number; lower first). `dashboards_widget`: | Field | Kind | Meaning | |---|---|---| | `dashboard_id` | ref `dashboards_dashboard`, required | The dashboard the tile sits on. | | `title` | text, required | Tile title. | | `kind` | select, required: `kpi`, `bar`, `line`, `pie`, `table` | How the tile is drawn. | | `model_name` | text, required | Model to read, as in `/api/meta`, for example `sale_order`. | | `measure_field` | text | Number or money field to aggregate; empty when counting. | | `aggregate` | select, required: `count`, `sum` | How records are combined. `sum` needs `measure_field`. | | `group_by` | text | Field to split by (a select, ref or date field), for `bar`, `line`, `pie` and `table`; empty for a single `kpi` value. | | `filter` | longtext | A JSON object of field name to exact value, for example `{"state":"paid"}`; empty means all records. Only equality is defined. | | `position` | number | Order of the tile on the dashboard; lower first. | ## 10. Limitations Declarative X1 deliberately cannot do the following. Plan around them or wait for X2 and X3. - **No computed fields.** Totals, balances, ages, taxes and margins are entered by whoever creates the record. The accounting package does not check that debits equal credits. - **No sequences or unique constraints.** Numbers such as `INV/2026/0001` are free text; uniqueness and gaplessness are conventions the people entering them keep. Two records can have the same `tag` or `slug`. - **No row-level permissions.** Access is by model and field group only. Everyone holding `knowledge.user` reads every article regardless of its `visibility` field; that field is a label. - **No side effects.** Actions change one field. They do not send email, create invoices, post to a ledger, move stock or call a payment provider. The email, sign, eCommerce and POS packages record what happened or should happen; something else must do it. - **Governed state, but not locked records.** A state field is protected (section 6.1), but every other field of a posted or signed record can still be edited by anyone who holds the `write_group`; "immutable once posted" is not enforced. Keep such data in a model with a stricter `write_group`. Data written outside the commands bypasses the rules. Nothing stops two servers racing on one PostgreSQL database between the check and the write of a transition (one server serialises them). - **Delete is plain**: `delete` removes the record for good and refuses while another record refers to it through a `ref` (the error lists the referencing models, counts and up to five ids). There is no cascade, no soft delete and no tombstone; the check sees only models that are installed now. Personal-data erasure still uses the dedicated `erase` command of the owning module (`party.erase`), which anonymises in place and keeps the references valid; `delete` is not a substitute for it. There is no cross-record validation (dates in order, totals matching lines). - **No migrations**: see section 7. - **Search is evaluated in memory**: `list`/`search` filter, sort and page on the server (section 4.1), but the server reads the model's records from the store to do it, on all three backends, so a very large table is slow; there are no indexes, no full-text search and no grouping or aggregation on the server. Filtering and sorting on a field the caller cannot read in clear (not permitted, withheld, or masked for an agent) is refused. - Limits: 256 KiB, 50 models, 200 fields per package; no floats; one field per action; at most 50 filter conditions, 5 order keys and 5000 rows per call. ## 11. FAQ **Why must I write `class = "none"`?** So every field has been looked at. The D41 gate refuses a field with no class; "not personal" is a statement you make on purpose. **My field holds a person's name; which class?** `public_directory` if every employee may see it (an author or a department head), otherwise `internal`, plus `personal_data = true`. Pay or bank data: `financial`. Health, union: `special_category`. **Why does an agent get "approval required"?** The action has `approval = true`. That is by design: agents draft, humans commit (D18). **Can I make two different transitions to the same state?** Yes, with two actions with different names and `from` values. **Can an action have several `from` states?** No; declare one action per `from`. Leave `from` out only if the step is valid from anywhere. **Can a ref be optional?** Yes; only `required = true` forces it. A self-ref cannot be required. **How do I store a currency?** Add a `text` field (`currency = "EUR"`) next to the `money` fields. Amounts are integers in minor units (cents). **How do I model a time of day?** A `date` plus `number` fields of minutes since midnight, as `planning` does. **How do I add a field to an installed module?** Add it to the file, raise `version`, and install the file again. Old records have no value for it. **Where do I put a model that belongs to two apps?** Models are named after the module that owns them. Add the module that owns the model to `depends` and `ref` to it. **Can a module have no models?** The file parses with no `[[model]]`, but it adds nothing. **Where are decisions recorded?** D11 (tiers), D45 (packages and the admin), D46 (backoffice and Customize), D47 (typed fields), D48 (governed fields), D49 (delete and search) in `docs/DECISIONS.md`; data classes in `docs/CONVENTIONS.md` section 5. Source: https://obop-platform.sliplane.app/docs/reference/module-manifest/ --- # REST API reference The REST API is generated from the installed modules. There is no OpenAPI document yet; this page lists everything that exists. All bodies are JSON and all calls except `login` need a bearer token. ```text Authorization: Bearer obop_... Content-Type: application/json ``` ## Endpoints | Method and path | Purpose | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /api/auth/login` | Body `{"username","password"}`. Returns `{"token"}`, a session token valid for 12 hours. `401` for wrong credentials, `429` when locked out. | | `POST /api/auth/agent-token` | Body `{"scopes":[...],"label":"..."}`. A person creates a scoped agent token. Returns `{"token"}` once. | | `GET /api/commands` | The commands this caller may use: `{"commands":[{"id","description","needs_approval"}]}`. | | `GET /api/meta` | The modules, models, readable fields and actions this caller may see. Drives the web app. | | `POST /api/{model}/{command}` | Runs a command. The body is the command input. | | `POST /api/approve/{plan_id}` | A person approves a proposed plan. | Instances also serve the web app from `/`. The control plane serves its own endpoints under `/api/platform/` (sign-up, sign-in, instances); they are used by the sign-up app and are not covered here. ## Command responses Success: `200` with ```json {"denied": ["party.tax_id"], "value": { ... }} ``` `denied` lists the field names this caller may not see. Needs approval (for an agent): `202` with `{"approval_required":true,"plan_id":"..."}`. ## Generated commands Every model gets these. For model `lead`: | Command | Input | Result | | ------------- | --------------------------------------------- | ------------------------------------------------------------------------------------- | | `lead.get` | `{"id"}` | The record. | | `lead.list` | optional `filter`, `order`, `limit`, `offset` | An array of records. | | `lead.search` | same as list | `{"rows":[...],"total":N}`, where `total` counts matches before `limit` and `offset`. | | `lead.create` | the fields | The new record, with its `id`. Governed fields start at their first option. | | `lead.update` | `{"id", ...fields}` | The updated record. Changing a governed field runs the matching action. | | `lead.delete` | `{"id"}` | `{"id"}`. Refused while another record refers to it. Agents only propose it. | Each workflow action is one more command: `lead.qualify`, `invoice.post`. Input is `{"id"}`, plus any `required` fields it needs. ## Filters, order and paging ```json { "filter": [ { "field": "stage", "op": "in", "value": ["new", "qualified"] }, { "field": "expected_revenue_minor", "op": "gte", "value": 100000 } ], "order": [{ "field": "expected_revenue_minor", "dir": "desc" }, { "field": "title" }], "limit": 50, "offset": 100 } ``` - Conditions are combined with AND; at most 50. - Operators: `eq`, `ne`, `contains` (case-insensitive substring, text only), `in` (up to 1000 values), `gt`, `gte`, `lt`, `lte` (not for `bool` or `ref`), `is_null`. `ne` also matches records where the field is empty; other operators never match an empty value. - `order`: up to 5 keys, `dir` `asc` (default) or `desc`; empty values sort last. - `limit` 1 to 5000 (default: no limit), `offset` 0 or more. - `id` can be used as a field. Unknown keys, operators or malformed values are `400`. - Filtering and sorting only work on fields the caller can read in clear. Other fields fail with `unknown field .`. ## Field values | Kind | JSON | | ------------------ | ------------------------------------------------ | | `text`, `longtext` | string | | `number` | integer (no floats) | | `money` | integer in minor currency units: 12.34 is `1234` | | `date` | `"YYYY-MM-DD"`, a real calendar date | | `bool` | `true` or `false` | | `select` | one of the field's options | | `ref` | the id of an existing record of the target model | ## Status codes | Code | Meaning | | ---- | --------------------------------------------------------------------------------- | | 200 | Success. | | 202 | Approval required; nothing changed. | | 400 | Input refused. `{"error":"..."}` says why. | | 401 | Missing, unknown, expired or revoked token; or wrong sign-in. | | 404 | Unknown, hidden or forbidden command; unknown plan. Indistinguishable on purpose. | | 415 | Body not sent as `application/json`. | | 429 | Sign-in lockout (five failures, one minute). | ## MCP The same commands are also exposed as MCP tools at `POST /mcp` (Streamable HTTP, protocol revisions 2025-11-25, 2025-06-18 and 2025-03-26), with the same bearer token, permissions, masking and approvals. A command `lead.search` is the tool `lead__search`; its input is the REST body, described by a JSON Schema generated from the model. An approval-required step is a normal result with `"status": "approval_required"` and a `plan_id` instead of HTTP 202; a refused input is a tool result with `isError: true` instead of HTTP 400; a hidden or unknown tool is a JSON-RPC error instead of 404. The OpenAPI document advertises the endpoint in `x-mcp`, and `GET /api/meta` in a `Link` header (`rel="mcp"`). Setup, limits, discovery documents and client configuration: [Connect over MCP](/docs/agents/mcp/). ## Versioning There is no version header or deprecation policy yet. The product plan calls for a dated version header; until it exists, treat the API as unstable during the beta and check the [changelog](/changelog/). See [Use the REST API](/docs/agents/rest/) for worked examples and the [module manifest reference](/docs/reference/module-manifest/) for how fields and actions are declared. Source: https://obop-platform.sliplane.app/docs/reference/rest-api/ --- # Security and privacy This page describes what the code does today. For how to report a problem, see [Security](/security/). ## What is built | Area | What obop does | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Passwords | Argon2id hashes. Minimum 12 characters when created from the command line. Sign-in uses a dummy hash for unknown users so timing does not reveal who exists. | | Sessions and tokens | Bearer tokens are random 256-bit values stored only as SHA-256 hashes. Sessions expire after 12 hours. Five failed sign-ins lock a username for a minute. | | Agents | Scoped tokens that can only narrow their owner's groups. An agent cannot create tokens or approve plans. | | Data classes | Every field has a class. A field without one cannot be installed. Personal data cannot be class `none`. | | Masking | Agents see personal data as reversible placeholder tokens and sensitive classes not at all. Tokens are restored on write only if the agent could read the record. | | Approvals | Irreversible actions and deletes are proposals when an agent calls them. | | Command boundary | One dispatcher serves REST, MCP and the CLI, so no surface skips permissions or masking. Unknown, hidden and forbidden commands look the same. | | Isolation | On the hosted service each business has its own database and database role. | | Auth storage | Users and tokens sit in storage outside the module registry, so no generated command can read them. | | Secrets | Fields of class `secret` are never returned to anyone. | ## Data you hold obop stores what your business enters. The hosted beta stores it in a PostgreSQL database that is yours alone. See [Privacy](/privacy/) for what the website and the sign-up service collect. ## What is not built yet Honest list, so you can decide: - **No audit log** of reads and changes yet, and no marking of who or which agent changed a record. - **No revoke command** for agent tokens and no expiry. A token works until its owner is removed. - **No row-level permissions.** Access is by model and field group. - **No encryption at rest or key management** beyond what your database provider gives you. - **No backups or point-in-time recovery** for the hosted service. - **No single sign-on, two-factor sign-in or passkeys.** Password sign-in only. - **No data export tool.** Self-hosted data is in your database. - **Masking is not free-text aware** and tokens live in server memory (not stable across restarts or sessions). - **No certification.** obop has no SOC 2, ISO 27001 or similar attestation, and no independent penetration test yet. - **TLS** is the responsibility of your proxy or platform. - **Records are JSON documents.** The database does not enforce field types; the command layer does. ## Data protection notes - Contacts has an `erase` action that anonymises a party in place and keeps references valid. It needs approval and the `privacy.officer` group. Other apps have no erase action yet. - Marking `personal_data` correctly is what lets masking and, later, access and erasure requests work. The install gate refuses personal data classed `none`. - A Data Processing Agreement and sub-processor list do not exist yet. The [Terms](/terms/) and [Privacy](/privacy/) pages are drafts. ## Reporting vulnerabilities Use a private GitHub security advisory. Details and what to expect are on the [Security](/security/) page and in `/.well-known/security.txt`. Source: https://obop-platform.sliplane.app/docs/reference/security/ --- # Self-hosting and operations ## The pieces | Piece | Command | What it does | | ------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Instance | `obop serve` | One business: REST, MCP over the CLI and the web app. SQLite locally, PostgreSQL when `OBOP_DATABASE_URL` starts with `postgres://`. | | Control plane | `obop platform serve` | Sign-up, instances, and the public website. Runs a provisioner per instance. | | Provisioners | `--provisioner local\|sliplane\|fake` | `local` starts child `obop serve` processes with SQLite; `sliplane` creates a database on a shared PostgreSQL and a Sliplane service per instance; `fake` is for tests. | ## One instance with Docker and PostgreSQL The repository has a root `Dockerfile` and a `docker-compose.yml`. The image contains the `obop` binary, the back office build (`/srv/backoffice`) and the website (`/srv/site`). By default it runs one instance: ```bash docker compose up --build ``` starts PostgreSQL 18 and obop together, with obop published on `127.0.0.1:8080`. For production, run the image against your own database: ```bash docker run -d --name obop -p 8080:8080 \ -e OBOP_DATABASE_URL=postgres://obop:secret@db.internal:5432/obop \ obop docker exec -it obop obop admin create alice --all-groups ``` Put a reverse proxy with TLS in front. obop speaks plain HTTP. ## The control plane and this website The control plane serves the website with a plain static file server and the sign-up app under `/app/`: ```bash deploy/build-site.sh cargo run -- platform serve --static web/site --instance-static web/apps/backoffice/dist ``` The image does the same with `--static /srv/site`. Run it with `obop platform serve --addr 0.0.0.0:8080 --static /srv/site --provisioner sliplane`. ## Sliplane The reference deployment runs on Sliplane, in one project with three kinds of service: a PostgreSQL 18 service on an internal network (one database and one role per business), the control plane (public, its own SQLite on a volume) and one service per customer instance built from the same repository. The control plane provisions through the bundled `sliplane` CLI and needs `SLIPLANE_API_KEY` as a secret. All settings are in [Environment variables](/docs/reference/environment/). ## Operating it ### Known limits - **Build time and memory.** Each instance builds the Rust workspace on the server. That is slow and competes with other services; a registry image would remove it. - **TLS** ends at the platform's proxy and the instance speaks HTTP behind it. - **Upgrading instances is manual.** Auto-deploy is off; redeploy each instance. - **No schema migrations.** Records are JSON documents, and installing a newer package version does not migrate existing records. Prefer adding optional fields. - **Backups.** Nothing backs up the database server for you. Use `pg_dump` or your provider's snapshots, and test a restore. A single SQLite file can be copied while the server is stopped. - **Full export.** The product plan promises a full export on every plan; the tool is not built. Your database is your export. - **Monitoring.** There is no metrics endpoint yet. Watch the process and the database. - **Audit log.** There is no hash-chained audit trail yet. ### Sizing An instance is one process. SQLite is enough to evaluate. Use PostgreSQL for more than one user or for anything you cannot lose. `list` and `search` read the whole model into memory on the server, so very large tables are slow; there are no indexes yet. ## Security basics - Run behind TLS. Never expose `--dev-auth`. - Keep `OBOP_DATABASE_URL`, `SLIPLANE_API_KEY` and agent tokens in a secret store. - Create administrators with a long password (12 or more characters is enforced) and give day-to-day users only the groups they need. See [Security and privacy](/docs/reference/security/) and [Getting started](/docs/getting-started/). Source: https://obop-platform.sliplane.app/docs/reference/self-hosting/ --- # Marketplace ## The bundled catalog These 22 packages ship with obop (the `packages/` directory of the repository, compiled into the program). Administrators install them from _Customize_, then _Marketplace_, or with `obop modules install-from ID`. The five built-in apps (Contacts, Products, CRM, Sales and Invoicing) are not listed here because they are always present. Each name links to its app page. | App | Package id | Version | Description | |---|---|---|---| | [Project](/apps/projects/) | `projects` | 0.2.0 | Projects, milestones and tasks with assignees, deadlines and status flows. Progress is tracked by status, not computed from task effort. | | [Helpdesk](/apps/helpdesk/) | `helpdesk` | 0.2.0 | Support tickets with priority, severity, service-level targets and an open, in progress, resolved and closed flow. Targets are recorded, not enforced or timed automatically. | | [Fleet](/apps/fleet/) | `fleet` | 0.2.0 | Vehicles, drivers and service history for a small fleet. Tracks status and service records; it does not compute running costs or fuel use. | | [Inventory](/apps/inventory/) | `inventory` | 0.1.0 | Warehouses, stock locations, stock levels and stock moves. Quantities are entered and validated by people; they are not updated automatically by moves. | | [Purchase](/apps/purchase/) | `purchase` | 0.1.0 | Vendors, purchase orders and order lines with a draft, sent, confirmed, received and cancelled flow. Order totals are entered, not summed from the lines. | | [Manufacturing](/apps/manufacturing/) | `manufacturing` | 0.1.0 | Bills of materials and manufacturing orders with a draft, confirmed, in progress and done flow. Component stock is not consumed automatically. | | [HR](/apps/hr/) | `hr` | 0.1.0 | Employees, departments and leave requests. Personal data is classified strictly: pay, bank and national id are visible to managers only, and health and union data to the special reader group. | | [Timesheets](/apps/timesheets/) | `timesheets` | 0.1.0 | Time spent on project tasks, entered in minutes, with a billable flag and an approval step. Needs the Projects package. | | [Planning](/apps/planning/) | `planning` | 0.1.0 | Shifts for employees, with roles and reusable templates. A shift has a day and a start and end in minutes since midnight (0 to 1439; 540 is 09:00). A shift with no employee is an open shift. Workflow: open, published, confirmed by the employee. Needs the Human Resources package. | | [Field Service](/apps/field-service/) | `field_service` | 0.1.0 | On-site jobs for customers, done by a technician: scheduled, en route, in progress, done or cancelled. A job records the customer, the site, the technician, the planned day and start (minutes since midnight), the parts used and whether the customer must sign off. Site address and contact details are personal data. Needs the Human Resources and Products packages. | | [Point of Sale](/apps/pos/) | `pos` | 0.1.0 | Shop tills: a point-of-sale configuration, cash-controlled sessions that are opened and closed, orders with lines, and payment lines per method. A refund and the closing of a session need a human approval from a manager; agents may only propose them. Totals, change and cash differences are entered, not computed. Card numbers must never be stored; keep only the payment provider's reference. Needs the Products package. | | [eCommerce](/apps/ecommerce/) | `ecommerce` | 0.2.0 | An online shop: categories, product listings with translations, shipping methods, discount codes, web orders with lines and a fulfilment flow of pending, paid, shipped, delivered, cancelled and refunded. When the instance runs with the public site enabled, visitors browse the published listings and place orders without an account; the server prices every order from the published listing and never trusts the browser. There is no payment provider: an order starts as pending and the shop contacts the buyer. Refunds need a human approval from a manager; agents may only propose them. Customer name, email and addresses are personal data and are never public. Needs the Products package. | | [Website](/apps/website/) | `website` | 0.2.0 | Content for a public website: pages with a slug and a draft, published, archived flow, navigation menu items, blog posts with a byline and a publish date, translations of both, and the form submissions visitors send in. When the instance runs with the public site enabled it serves the published content to visitors as a server-rendered website with SEO and no login; only the fields marked public are ever visible to them. Publishing makes content public and needs a human approval, agents may only propose it. Senders are personal data and are never public. | | [Email Marketing](/apps/email-marketing/) | `email_marketing` | 0.1.0 | Mailing lists, the contacts on them with their consent status and date, and campaigns with a draft, scheduled, sent flow. Sending is irreversible and reaches real people, so it needs a human approval from a manager; agents may only propose it. Campaign statistics are numbers entered or imported, not computed. Contacts are personal data: keep consent_status and consent_date accurate and never mail a contact that is not opted_in. | | [Documents](/apps/documents/) | `documents` | 0.1.0 | A register of business documents: folders, document records with kind, owner, expiry and confidentiality, and a draft, published and archived flow. It stores references, not file contents. | | [Sign](/apps/sign/) | `sign` | 0.1.0 | Document templates and signature requests with a draft, sent, signed, declined, expired flow. A request names the signer (personal data) and keeps the signature evidence as references. A signed request is immutable by convention only: the platform cannot yet lock a record, so nobody should edit a request once it is signed, and the document_hash records what was signed. Sending and recording a signature need a human approval; agents may only propose them. | | [Subscriptions](/apps/subscriptions/) | `subscriptions` | 0.1.0 | Recurring plans and customer subscriptions with a trial, active, past due and cancelled flow. It records the next invoice date; it does not create invoices on its own. | | [Dashboards](/apps/dashboards/) | `dashboards` | 0.1.0 | Dashboards and their widgets stored as data, for a UI to render. A dashboard has a title and an owner. A widget describes one tile with these fields: kind (kpi, bar, line, pie or table), model_name (the model to read, for example sale_order), measure_field (the field to aggregate; empty for count), aggregate (count or sum; sum needs a number or money measure_field), group_by (field to split by, for bar, line, pie and table; empty for a single kpi value), filter (a JSON object of field names to exact values, for example {"state":"paid"}; empty means all records) and position (order on the dashboard, lowest first). The package only stores the definition; it does not check that the model or fields exist and it computes nothing. | | [Accounting](/apps/accounting/) | `accounting` | 0.1.0 | Chart of accounts, journals, tax rates, journal entries with debit and credit lines, fiscal years and periods, and bank statement lines. Amounts are money fields in minor currency units and tax rates are numbers in basis points (2000 means 20.00 percent). Nothing is computed: balancing of debits and credits, tax amounts and entry numbers are the responsibility of whoever enters them. Posting an entry and closing a period or fiscal year need a human approval and the stricter accounting.approver group; agents may only propose them. | | [Knowledge](/apps/knowledge/) | `knowledge` | 0.1.0 | A wiki: articles arranged in a tree through a parent article, with a draft, published, archived flow, a visibility setting and tags. Visibility is a label for the UI and for people to respect; the platform has no row-level permissions yet, so everyone with the Knowledge groups can read every article. | | [Appointments](/apps/appointments/) | `appointments` | 0.1.0 | Bookable resources (people, rooms, equipment) and appointments with a date, start and end time and a status flow. Overlaps are not prevented automatically. | | [Expenses](/apps/expenses/) | `expenses` | 0.1.0 | Employee expense claims with a draft, submitted, approved and paid flow. Paying is a separate permission and needs a human approval; receipts are referenced by note, not stored. | Every package is checked on install: every field needs a description, a data class and a permission group, and personal data cannot be class `none`. Packages contain no code. ## Install a package In the web app: _Customize_, _Marketplace_, choose an app, _Install_. Installing needs the administrator group. From the command line, against a database: ```bash obop modules market # list what is available obop modules install-from helpdesk # install one obop modules list # see what is installed ``` ## Publish your own marketplace A marketplace is any HTTP or HTTPS host that serves two things: ```text index.json a list of the packages packages/.toml the obop.toml of each package listed ``` `index.json` is an array of objects with `id`, `name`, `version`, `description` and an optional `author`: ```json [ { "id": "assets", "name": "Assets", "version": "0.1.0", "description": "A register of company equipment with a holder and a retire step that needs approval.", "author": "Example Ltd" } ] ``` Package ids may contain only lowercase letters, digits and `_`. Point an instance at your host with the environment variable `OBOP_MARKETPLACE_URL=https://example.com/market`. The instance shows the bundled catalog first and your host after it; if the same id exists in both, the bundled one wins. Every download is validated exactly like an upload, and responses are limited to 1 MiB. Package signing and paid listings are not built. Treat a third-party marketplace like any other software source: read the `obop.toml` before you install it. A package cannot run code or reach the network, which limits what a bad one can do, but it can still declare misleading fields or permissions. ## Write a package A package is one text file. The [module manifest reference](/docs/reference/module-manifest/) has a full tutorial, every key and every validation error. Test it without a database: ```bash obop modules check my-package.toml ``` ## Limits today No signing, no ratings, no paid listings, no automatic updates, and no data migrations between versions. Uninstalling keeps the data. Source: https://obop-platform.sliplane.app/marketplace/ --- # Platform ## One instance per business Each business runs in its own **instance** with its own database, its own users and its own address. On the hosted service a control plane creates the instance for you after sign-up and gives you its address; the person who signed up becomes its first administrator. You can also run the same program yourself. See [Instances](/docs/concepts/instances/). Storage is SQLite for trying things out and PostgreSQL for real use. Records are stored as JSON documents for now; the command layer checks field types, and a schema manager is planned. ## Modules are apps An app is a **module**: a description of models, fields, permission groups and workflow actions. Five modules are built in (Contacts, Products, CRM, Sales, Invoicing). Twenty-two more are bundled as packages. You add apps from the marketplace in a click, or upload your own file. Because every module goes through one registry, a contact is the same contact in CRM, Sales and Invoicing, and every module gets the same API, screens, permissions and privacy rules. See [Apps and modules](/docs/concepts/apps-and-modules/) and the [list of apps](/apps/). ## Marketplace The marketplace is a static host serving `index.json` and one `obop.toml` per package. The bundled catalog ships inside the program; you can point an instance at your own host too. Every download is validated like an upload. See [Marketplace](/marketplace/). ## Permissions Permission groups such as `crm.user` or `invoicing.manager` are named by models, fields and actions. People hold groups; agent tokens hold a subset of their owner's groups. See [Permission groups](/docs/concepts/permission-groups/). ## Data classes and masking Every field declares a data class (none, public directory, internal, confidential HR, special category, financial, secret) and whether it is personal data. People see values under their permissions. Agents see personal data as stable placeholder tokens and sensitive classes not at all, and can pass tokens back to refer to a value without seeing it. See [Data classes and masking](/docs/concepts/data-classes-and-masking/). ## Workflows and approvals State changes are named actions. An action can need a stricter group and can need approval. An agent can only propose an action that needs approval: the server returns a plan and changes nothing until a person approves it. Deleting records is also proposed by agents. See [Workflows and approvals](/docs/concepts/workflows-and-approvals/). ## Three surfaces, one set of commands | Surface | For | How | | ------- | ------------------------- | ---------------------------------------------------------------------------------------------- | | Web app | People | Generated from the module descriptions: lists, boards, forms. | | REST | Scripts, services, agents | `POST /api/{model}/{command}` with a bearer token. [REST reference](/docs/reference/rest-api/) | | MCP | AI assistants | `obop mcp` over stdio, one tool per command. [Connect over MCP](/docs/agents/mcp/) | | CLI | Shells and cron | `obop call model command key=value`. [CLI reference](/docs/reference/cli/) | All of them call the same dispatcher, so permission checks, masking and approvals are identical. ## What it is not (yet) Be aware before you commit: there are no code tiers, so modules cannot compute fields or run side effects; there are no row-level permissions; there is no audit log, no data export tool and no backups for the hosted service. The [roadmap](/roadmap/) lists what is built and what is not. Source: https://obop-platform.sliplane.app/platform/ --- # Privacy > **This is a draft.** It describes what the software does today in plain language. It is not legal advice and is not a final, reviewed notice. It will be replaced after review by counsel. Do not rely on it as a contract. ## Who is responsible The obop project, run by SpaceCorps. There is no data protection officer or EU representative named yet; those details will be added in the final notice. ## This website - The website sets **no cookies** and uses no analytics, advertising or third-party scripts, fonts or images. All fonts and scripts are served from this site. - Your chosen colour theme and the dismissal of the language hint are stored in your own browser (local storage). They never leave your device. - Like any web server, the server that hosts this site processes your IP address and the pages you request to deliver them. We do not use that to build profiles. ## Sign-up and the hosted beta When you create an account and an instance we process: | Data | Why | Where | | --------------------------------------- | ----------------------------------------------- | ----------------------------------------------- | | Email address | Sign in, and to identify the account | The control plane's database | | Password | Sign in. Stored only as a one-way Argon2id hash | The control plane and, as a hash, your instance | | Instance name and address | To provision and show your instance | The control plane | | Everything you enter into your instance | To provide the service | A database belonging to your instance | We do not sell personal data. We do not use the data in your instance for anything but running it. ## Subprocessors The hosted beta runs on Sliplane. The list of subprocessors and the places data is processed will be set out in the final notice and in a data processing agreement. Do not use the beta for regulated or special-category data. ## How long Account data is kept while the account exists. Deleting an instance deletes its database. Backups of the hosted databases do not exist yet. ## Your rights Depending on where you live you may have rights to access, correct, delete, restrict or move your data and to object to processing. Until a mailbox exists, open a request through the channels on the [Contact](/contact/) page without including personal data, and we will arrange a private way to continue. ## Children obop is not intended for children. ## Changes This draft will change. The final notice will carry a date. Source: https://obop-platform.sliplane.app/privacy/ --- # Roadmap obop is in beta. This page separates what runs today from what the plan describes. The source documents are `docs/ROADMAP.md` and `docs/DECISIONS.md` in the repository. ## Built today - **Kernel:** one command dispatcher behind REST, MCP over stdio and the CLI, with permission groups, field-level access, data classes, masking for agents, approvals and governed state fields. - **Storage:** SQLite and PostgreSQL. - **Apps:** five built in (Contacts, Products, CRM, Sales, Invoicing) and 22 declarative packages in the marketplace, installable and removable at runtime. - **Users:** Argon2id passwords, sessions, scoped agent tokens. - **Web app:** generated screens: launcher, list, board, record panel, Customize (theme, apps, module builder), light and dark themes. - **Hosted beta:** a control plane that creates instances with their own database, on Sliplane. - **This website:** multi-page, static, in seven languages, with Markdown copies of every page. - **Tests:** Rust tests, and a Playwright end-to-end suite over the built app. ## Partly built or simplified - Bundled apps record data but compute nothing: totals, balances, stock levels, tax amounts and numbering are entered by people. - Accounting stores a chart of accounts, entries and periods, but does not check that debits equal credits. - Knowledge visibility and Sign immutability are conventions, not enforced rules. - MCP exists over stdio only, with minimal tool schemas. ## Not built yet - **Code tiers** for modules: expressions (CEL), WebAssembly components, remote apps. Until then packages cannot compute or call out. - **Remote MCP** (HTTP transport with OAuth), so hosted instances are not reachable by desktop MCP clients. - **Agent token management:** a screen, listing, expiry and revocation. - **Audit log**, row-level permissions, single sign-on, two-factor sign-in. - **Schema manager and migrations**; records are JSON documents. - **Backups, point-in-time recovery and a full export tool.** - **Localisation of the application** itself. The website is localised; the web app is English. - **Country packs** (tax, e-invoicing, charts of accounts for Germany, France, Poland) and the e-invoicing formats the plan names. - **Importers** from other systems. - **Billing.** Nothing is charged; plans are planned. ## The plan in waves The product plan names five waves. All dates are proposals. | Wave | Window | Aims | | -------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | W0 Foundations | 2026-10 to 2026-12 | The kernel, first tagged preview build, security policy. | | W1 Loop | 2027-01 to 2027-09 | The seven-app loop (contacts, products, CRM, sales, invoicing, accounting, purchase and stock basics), MCP, code tiers X1, X2 and X4, a first country pack, public alpha and a paid beta. | | W2 Release | 2027-10 to 2028-06 | Services apps, more country packs, WebAssembly host, a 1.0 long-term-support release with five years of support. | | W3 Breadth | 2028-07 to 2029-06 | More apps (manufacturing, point of sale, website, e-commerce, helpdesk and others), payroll engine, custom domains, partner programme. | | W4 Ecosystem | from 2029-07 | Marketplace commerce, verticals, 2.0. | ## Decisions that are still open Several choices are recorded as proposals and wait for the project owner, including the licence (AGPL is proposed), the pricing and the exact calendar. Pricing on this site is therefore marked as planned and not charged. ## How to follow along The [changelog](/changelog/) is generated from the commit history. The repository has the decision register and the full roadmap. Source: https://obop-platform.sliplane.app/roadmap/ --- # Security ## Report a vulnerability Please report security problems privately, not in public issues or the forum. 1. Open a **private security advisory** on GitHub: [github.com/SpaceCorps/obop/security/advisories/new](https://github.com/SpaceCorps/obop/security/advisories/new). 2. Describe what you found, how to reproduce it, and what you think the impact is. Do not include real personal data. 3. Give us reasonable time to fix it before you publish. The same contact is in [`/.well-known/security.txt`](/.well-known/security.txt). ### What to expect obop is a young project run by a small team, so we cannot promise a response time yet. Our intent is to acknowledge a report, work with you on a fix, credit you if you want, and publish an advisory when a fix is out. There is no bug bounty. ### Please do - Test only against your own instance or a local build. - Stop and report if you reach data that is not yours. ### Please do not - Run denial-of-service tests against the hosted beta. - Access, change or delete other people's data. - Use social engineering or physical attacks. ## What protects you today - Passwords are stored with Argon2id; tokens are stored only as hashes. - Every field has a data class; agents get masked personal data and no sensitive classes. - Agents act with scoped tokens and can only propose irreversible steps; a person approves. - One dispatcher serves every surface, so permissions and masking cannot be skipped by choosing a different one. - On the hosted service each business has its own database and role. ## What is missing No audit log, no token revocation, no single sign-on or two-factor sign-in, no backups for the hosted service, no certification and no independent penetration test yet. The [Security and privacy reference](/docs/reference/security/) lists these in full. ## Supply chain The repository pins its JavaScript dependencies in a single lockfile and its Rust dependencies in `Cargo.lock`. Releases are not signed and no software bill of materials is published yet. Source: https://obop-platform.sliplane.app/security/ --- # Terms > **This is a draft.** It is not legal advice and has not been reviewed by counsel. It will be replaced before any paid plan starts. Do not rely on it as a contract. ## The beta The hosted service is a free beta. It is provided as is, may change or stop at any time, and comes with no service level agreement and no support commitment. Nothing backs up the hosted databases yet, so keep your own copies of anything you cannot lose. ## Your account and your data You are responsible for your account, your passwords and your agent tokens, and for what you and your agents do with them. The data you enter is yours. We process it only to run the service. ## Acceptable use Do not use the service to break the law, to attack other systems or people, to store data you have no right to store, to send spam, or to interfere with the service or other users. We may suspend an instance that does. ## Open-source software The obop software is open source; its licence is stated in the repository and governs your use of the code when you self-host. These terms are about the hosted beta and this website only. ## Prices Prices on this site are planned and are not charged. If we begin charging, we will say so in advance and nothing will be billed without your consent. ## Liability To the extent the law allows, the beta is offered without warranties, and we are not liable for loss of data or profit from using it. This section will be replaced by counsel-reviewed text. ## Changes and contact We may change these terms; the final version will carry a date. Questions: see [Contact](/contact/). Source: https://obop-platform.sliplane.app/terms/