# GoLocomotive docs > Management software for French small businesses, made from a base for your trade and adapted to your company. You ask for a change, our AI agents make it in your own app, and you approve it on a running preview before it goes live. # What is GoLocomotive > A custom app for your business, built by AI agents, changed when you ask, approved by you on a running preview, and run by us. GoLocomotive builds one app for your business: your data, your screens, your rules. You describe what you need in plain words. AI agents write the app and change it each time you ask. You see every change in a running preview of the app before your users do, and you decide what ships. We run the app on our servers, behind several layers of protection. These docs are the reference for that whole loop. They start with what you do as a business owner, and end with what a developer or an AI agent needs to read and change the app's code. ## Three ideas 1. **One app per business.** Your app is not a tenant in a shared system. It has its own code, its own process and its own database file. Nothing in it is shared with another customer. 2. **You judge by looking.** Every change comes to you as a card with a picture of the app before and after. You approve it, ask for a fix, or reject it. Nothing waits for a long specification. 3. **Your own AI can work in your app.** Each app can act as an MCP server. You connect the AI you already use (Claude, or another assistant that speaks MCP), and it reads and writes your data as you, under the same rules as the screens. ## Where to start | you want to | read | |---|---| | understand the loop from a request to a live change | [Getting started](https://golocomotive.fr/docs/start/getting-started.md) | | ask for a change and review it | [Using the portal](https://golocomotive.fr/docs/using.md) | | know what an app can already do | [What an app can do](https://golocomotive.fr/docs/apps.md) | | let your own AI work in your app | [Connect your AI](https://golocomotive.fr/docs/connect.md) | | know where your data lives and who can reach it | [Trust](https://golocomotive.fr/docs/trust.md) | | ask for something bigger | [Extend your app](https://golocomotive.fr/docs/extend.md) | | read or write the code | [Developers](https://golocomotive.fr/docs/developers.md) and [Reference](https://golocomotive.fr/docs/reference.md) | ## How an app is built An app is small on purpose. It holds a schema (the data and its rules), its screens and a few server hooks. Everything generic comes from one shared toolkit, `locosdk`: the database, sign in, the data screens, mail, backups, the review cards and the MCP server. A small app is easier for an agent to change safely, and easier for you to check by looking at it. The storage, the filter language and the web API follow PocketBase, an open source backend that many developers and most AI models already know. A PocketBase app can move to GoLocomotive with its database as it is. See [Coming from PocketBase](https://golocomotive.fr/docs/developers/pocketbase.md). ## For AI agents Every page of these docs also exists as plain markdown: replace the last `/` of its address with `.md`, or follow the link at the bottom of each page. [llms.txt](/llms.txt) lists every page with one line each, and [llms-full.txt](/llms-full.txt) holds all of them in one file. Source: https://golocomotive.fr/docs/ # Start here > What happens between your first message and an app your team uses every day. This section is for the person who decides: the owner or the manager who will ask for the app and judge it. It uses no technical words beyond the ones the portal shows. ## The loop in one picture ```text you ask ──▶ an agent builds ──▶ you see before and after ──▶ you decide ▲ │ └───────────────── a fix, a question, or the next request ◀─────────┘ ``` 1. You write a request in the portal: a new app, or a change to the one you have. You can draw rectangles on a picture of the screen to show what you mean. 2. An AI agent takes the request. It changes the app's code, checks its work, and takes a picture of the screen before and after the change. 3. The change comes back to you as a card, next to the live app. 4. You approve it, approve it with a small fix, or reject it with a comment. A rejection or a fix goes straight back to the same agent. 5. When you are ready, you release the approved changes and deploy them. Your users see a short "what's new" note the next time they sign in. ## Pages in this section - [Getting started](https://golocomotive.fr/docs/start/getting-started.md): your first app, step by step. - [How it works](https://golocomotive.fr/docs/start/how-it-works.md): what the agents do, what you do, what we do. - [Your first week](https://golocomotive.fr/docs/start/first-week.md): good first requests, and how to write a request an agent gets right the first time. Source: https://golocomotive.fr/docs/start/ # Getting started > Your first app, from the description you type to the first version your team signs in to. This page follows one app from its first line to its first live version. Each step names the part of the portal you use. Before any price, we show you a working first version of your app on made up sample data: see [How a quote works](/en/pricing/#quote). The portal itself is described in [Using the portal](https://golocomotive.fr/docs/using.md). ## 1. Open your portal We send you a link. It opens your portal in your browser and keeps you signed in on that browser. The portal shows your apps on the left, the review cards in the middle, and your app itself on the right. ## 2. Describe the app Click **+ new** in the apps column. Give the app two things: - **A name**: lowercase letters, digits and single dashes, starting with a letter, 40 characters at most. For example `field-service` or `stock`. The name is also the first part of the app's address once it is live. You can change the address later. - **A description**: what the app is for, who uses it, and what they do in it. Write it as you explain it to a new colleague. > **Tip.** Name the things your team handles (orders, clients, tickets, machines), the people who use the app (a manager, a driver, a client), and what each one must and must not see. That one paragraph saves several rounds. When you click create, the portal makes the app at once. It is a small working app with a sign in page, and it shows in the preview on the right. An agent then starts to build what you described. ## 3. Watch the first build The build is a card in the review column with a live status: the agent's last action, how long it ran, and what it spent. You do not need to stay on the page. The agent works on its own, and the card waits for you. If the agent needs an answer from you, it does not wait in a chat. It turns the card into a question and stops. You answer on the card, and the same agent continues from where it stopped. ## 4. Judge the result When a part is ready, the agent sends a card: a short title, a few lines on what changed, and a picture of the screen before and after. Compare the two, try the change in the live preview next to it, then answer: - **Approve** when it is right. - **Approve with a fix** when it is right but for a small detail. Draw a rectangle on the detail and say what to change. - **Reject** when it is wrong. Say why, and draw on the picture where it helps. A fix or a rejection goes back to the same agent, with your words and your rectangles. The next card replaces the old one. More in [Review a change](https://golocomotive.fr/docs/using/review.md). ## 5. Add your users Open the **Users** tab. Create an account for each person: an email, a role, and a password you type or let the portal generate. The portal shows the password once, with a text you can copy and send to the person. More in [Users and access](https://golocomotive.fr/docs/using/users.md). ## 6. Release and go live Open the **Releases** tab. The changes your agents made wait under "Unreleased", each one as a line your users can read. Click **Release v1** to freeze them into version 1, then **Deploy v1 to prod**. The portal shows each step as it runs, then the address of your live app. Your live app is open to anyone who has an account in it: its own sign in page is the gate. If you prefer, you can also limit it to a list of internet addresses (your office, for example). See [App settings](https://golocomotive.fr/docs/using/settings.md). ## 7. Keep asking From now on, every change follows the same loop: you ask, an agent builds, you look, you decide. When the next version is ready, release it and deploy it the same way. Your users see a short "what's new" note at their next sign in. ## Next - [How it works](https://golocomotive.fr/docs/start/how-it-works.md): who does what, and why it is safe. - [Your first week](https://golocomotive.fr/docs/start/first-week.md): requests that work well. - [Connect your AI](https://golocomotive.fr/docs/connect.md): work in your app from Claude or another MCP assistant. Source: https://golocomotive.fr/docs/start/getting-started/ # How it works > Who does what between your request and your live app: you, the agents, the shared toolkit, and us. GoLocomotive has three parts that work together: your app, the agents that change it, and the portal where you meet them. This page explains each part in plain words. The technical version is in [Developers](https://golocomotive.fr/docs/developers.md). ## Your app - **One app, one process, one database.** Your app is a single program with its own database file. It serves its screens, its data and its admin. No other customer's data is in it, and no other customer's app runs in it. - **Its own code.** Each app is a separate code repository. Every change an agent makes is a saved step in its history, with a message that says what changed and why. The code and the data are yours: you get the full repository on request, at any time. See [Your data](https://golocomotive.fr/docs/trust/data-protection.md#take-it-with-you). - **Small on purpose.** The app holds only what is yours: the shape of your data and its rules, your screens, and a few actions that run on the server. Sign in, data screens, mail, backups and the rest come from one shared toolkit, `locosdk`. A small app is easier to change safely, and easier to check by looking at it. - **Two copies.** A working copy runs on our build machine: that is the preview you see in the portal, and the copy the agents change. The live copy runs on our production server, and it changes only when you deploy a release. ## The agents An agent is an AI coding assistant that works on one request in your app's code. It follows the same steps every time: 1. It reads your request, your words and the rectangles you drew. 2. It takes a picture of the screen as it is now. 3. It changes the code. The preview reloads by itself. 4. It runs the app's checks: the code compiles, the tests pass, the code style holds. 5. It writes one line for your users' "what's new" note, in your app's language. 6. It saves the change, takes the picture after, and sends you the card. An agent never asks you a question in a chat window that nobody reads. It asks on the card and stops. Your answer, your approval with a fix, or your rejection is its next instruction. Each agent works inside a closed space: it can change only your app's code, it can reach only the few internet services it needs, and each run has a spending limit. See [How agents are contained](https://golocomotive.fr/docs/trust/agents.md). ## The portal The portal is where you meet the agents. One page shows your apps, the cards, the open card and your live app side by side. From the same page you release and deploy, manage your users, and change your app's name, color, logo and address. See [Using the portal](https://golocomotive.fr/docs/using.md). ## Us We run the machines: the build machine where agents work and the production server where your live app runs. We keep the shared toolkit, the agent rules and the portal up to date. Your app receives toolkit updates the next time an agent works on it. ## Why this shape - **You judge results, not specifications.** A picture before and after, next to the live app, is faster to judge than a document. - **Nothing ships without you.** Agents can change the working copy. Only you release and deploy. - **One rule set for everyone.** The rules that decide who can read or change a record live in the app's server. The screens, the agents and your own AI all go through them. A screen that hides a button is a convenience, the server rule is the protection. Source: https://golocomotive.fr/docs/start/how-it-works/ # Your first week > Good first requests, how to write a request an agent gets right the first time, and the habits that keep the app clean. The first week sets the shape of your app. These steps put the important parts in place early, while they are cheap to change. ## Day 1: the data Ask for the things your team handles and how they relate. For example: > We take orders from clients. A client has a name, a phone and a city. An order belongs to one client and has a date, a status (received, in progress, ready, delivered) and an amount. Managers see every order. Staff see the orders of their own site. The agent turns this into the app's data and its rules, then into a list screen and a form for each thing. Judge the lists first: the columns, the filters, the sort. ## Day 2: who sees what Make the roles explicit. Name each kind of person, what they see and what they can change. The agent writes these as server rules, the part of the app that every screen and every AI must obey. Then sign in as each role in the preview and check. ## Day 3: the daily screen Ask for the one screen your team opens first every morning. Describe what a person does on it, not where the buttons go: > Each morning the manager checks which orders are late, calls the client, and moves the order to "in progress". ## Day 4 and after: one change per request - **One subject per request.** "The order list" and "the invoice mail" are two requests. Two small cards are faster to judge than one large card. - **Show, do not describe.** Open the request on the screen in question and draw a rectangle on the part to change. The agent receives the picture and your rectangles with your words. - **Say the result, not the method.** "A late order must stand out in the list" is better than "make the row red". The agent picks a method that fits the app, and you judge the result. - **Answer questions on the card.** A question card waits for you. The agent continues as soon as you answer. ## Habits that keep the app clean - **Approve with a fix** for small details. The agent makes the fix without a new round of review, unless the fix needs your eyes. - **Archive** what you no longer want, instead of leaving it open. - **Release often.** A release with a few lines is easier for your users to read than one with fifty. - **Read the "Unreleased" list** before each release. Each line is written for your users. If a line is unclear to you, it is unclear to them. ## What to expect A small change (a column, a filter, a label, a rule) comes back as one card. A new screen or a new kind of data takes longer, and it can arrive as several cards. A card always shows the before and the after, and the preview always shows the current working copy. Source: https://golocomotive.fr/docs/start/first-week/ # Using the portal > The one page where you ask for changes, judge them next to the live app, release, deploy, and manage users and settings. The portal is one page in four columns. Every border is a handle: drag it to make a column wider. Double click a border to put the column back to its size. The portal keeps your sizes for the next visit. ```text ┌ apps ──┬ Review | Releases | Users ───────────┬ preview ───────────┐ │ app a │ card list │ the open card │ your app, live │ │ app b │ who has it, live │ before and after, │ phone or desktop, │ │ + new │ for you, working │ rectangles, │ reload │ │ │ │ your answer │ │ └────────┴──────────────────┴───────────────────┴────────────────────┘ ``` - **Apps.** Your apps, and **+ new** to describe a new one. The menu button at the top left hides this column when you work on one app. - **Review.** The cards: what the agents send you and what you asked for. Open one to see it in full. See [Review a change](https://golocomotive.fr/docs/using/review.md). - **Releases.** What changed since the last version, the versions so far, and the buttons to release, deploy, revert and take the app offline. See [Release and deploy](https://golocomotive.fr/docs/using/releases.md). - **Users.** The people who can sign in to your app, in the working copy or the live app. See [Users and access](https://golocomotive.fr/docs/using/users.md). - **Preview.** Your app as it runs now, in a phone or desktop frame. It stays loaded while you move between cards. ## The header Above the preview, one line tells you where the app stands: - its name, and the version in the working copy (with `+3` when three changes wait to be released); - the version that is live, its memory use and how long it has run, and a link to its address; - if the app is not live yet: a **Deploy** button for the newest release, and the address it will have. The gear opens the [App settings](https://golocomotive.fr/docs/using/settings.md): name, color, logo, language, address and access. ## The address bar keeps your place The portal writes the open app, the tab, the open card and the preview's page into the address bar. A reload or a bookmark opens the same view. ## Pages in this section - [Ask for a change](https://golocomotive.fr/docs/using/requests.md) - [Review a change](https://golocomotive.fr/docs/using/review.md) - [Release and deploy](https://golocomotive.fr/docs/using/releases.md) - [Users and access](https://golocomotive.fr/docs/using/users.md) - [App settings](https://golocomotive.fr/docs/using/settings.md) Source: https://golocomotive.fr/docs/using/ # Ask for a change > Send a request with your words and a picture of the screen, and follow it until a card answers it. A request is how you ask for anything in an existing app: a new screen, a different column, a bug fix, a new rule. An agent starts on it as soon as you send it. ## Send a request 1. Open the app, then click **New request** at the top of the review column. 2. Pick what it is: **I want something** or **Something is broken**. 3. Write what must change. For a bug, write what is wrong and where. 4. Optional: click **Add a screenshot of the preview**. The portal takes a picture of the page the preview shows now. Drag on the picture to draw a rectangle, or click to drop a pin. Each mark gets its own comment field. 5. Click **Send**. > **Tip.** Open the preview on the page in question before you take the screenshot. The agent receives the page's address with the picture, so it opens the same page. ## What happens next Your request becomes a card in the list, marked **your request**. An agent takes it at once. The card's line in the list always says who works on it and what state it is in: | state | what it means | your move | |---|---|---| | **asks you** | the agent needs an answer, the question is on the line | answer on the card | | **working** | an agent works on it now: its last action, how long, what it spent | nothing | | **holds it** | an agent took it and is not running right now | nothing | | **done** | a card answers your request | judge that card | | **failed** | the run stopped with an error, the reason is on the line | retry, archive or delete | | **nobody on it yet** | no agent took it yet | wait, or ask us | The list puts what waits for you first: questions, then cards to judge. The counts at the top ("2 for you · 1 working") filter the list when you click them. ## Answer a question An agent that needs a decision asks it on the card and stops. Write your answer on the card. The same agent continues with your answer, with everything it already did. Nobody needs to type "go". ## When a run fails A failed card shows three buttons: - **Retry** continues the same agent from where it stopped, with the reason it stopped. - **Archive** closes the request. It no longer shows when **Hide handled** is on. - **Delete** removes the request and the records of the agent's runs on it, after a confirmation. Code the agent already saved stays in the app's history. It is refused while an agent still works on it. **Archive all failed** at the top of the list closes every failed card at once. ## Write a request that works the first time - One subject per request. - Say the result you want, not the method: "late orders must stand out", not "make the row red". - Point at the screen with a rectangle rather than describe where something is. - Name who it is for when roles differ: "managers see the total, staff do not". More examples in [Your first week](https://golocomotive.fr/docs/start/first-week.md). Source: https://golocomotive.fr/docs/using/requests/ # Review a change > Read a card, compare before and after, mark what is wrong, and give the answer that tells the agent what to do next. Every change an agent makes reaches you as a card. A card is one subject: one screen, one rule, one fix. Your answer to it is the agent's next instruction. ## What a card holds - **A title** that names what you look at. - **A few lines** on what changed, with the thing to judge in bold. - **Views.** At least a picture before and a picture after. A card can hold more views of the same subject: the phone and the desktop size, an empty list and a full one, a short video for anything that moves. - **The page it is about.** Open that page in the live preview next to the card and try the change yourself. - **Its thread.** Your earlier answers, the agent's replies, and the cards it replaces. ## Compare before and after Four ways to look at a pair, from the buttons above the pictures: | mode | shows | |---|---| | side by side | before on the left, after on the right | | swipe | one picture, a divider you drag across to reveal before or after | | onion | after over before, with a slider for how much shows through | | after | the after picture, the before when you hover it | Click a picture to open it full screen. The arrow keys move between views. ## Mark what is wrong Drag on a picture to draw a rectangle. Click to drop a pin. Give each mark a comment: "this label", "too close to the edge", "missing here". On a video, a mark keeps the moment you drew it, and the video pauses while you draw. ## Give your answer | answer | when | what happens | |---|---|---| | **Approve** | it is right | the card closes, the look is fixed | | **Approve with a fix** | right, but for a small detail | the agent makes the fix. It sends a new card only when the fix needs your eyes | | **Reject** | it is wrong | the agent fixes it and sends a new card that replaces this one | | **Later** | not now | the card is set aside, nothing happens | Your comment and your marks travel with the answer. An agent that is still running finishes its current step first, then receives your answer. Each answer is given to the agent once. > **Note.** Only your approval approves. An agent never marks its own work as approved, and it never calls a screen "checked" because it looks right to it. ## A new card replaces the old one When an agent answers your rejection, its new card replaces the old one. You judge each subject once, at its newest version. After an approval, an agent that needs to change the same screen again says what stays the same, so an approved look does not drift. ## Who has the card Each card in the list shows who holds it and in what state: **asks you**, **working**, **holds it**, **failed**, **done**, or **nobody on it yet**. The states are the same as for a request: see [Ask for a change](https://golocomotive.fr/docs/using/requests.md#what-happens-next). ## Keep the list short - **Hide handled** hides closed and archived cards. - The counts at the top filter the list to what waits for you, or to what agents work on now. - The open card stays in the address bar, so a reload keeps your place. Source: https://golocomotive.fr/docs/using/review/ # Release and deploy > Turn approved changes into a numbered version, put it live, go back one version, or take the app offline. Agents change the working copy of your app. Your live app changes only when you say so, in two steps: **release** (freeze the changes into a version) and **deploy** (put that version live). Both are in the **Releases** tab. ## The changelog Each change your users can see leaves one line under **Unreleased**, written by the agent for your users, in your app's language. A line can carry a tag: new, fix or change. For example: ```md ## Unreleased - new: Export the order list as a spreadsheet file. - fix: The total no longer counts cancelled orders. ``` Read these lines before you release. They are the text your users read. ## Release **Release v3** (the number is the next one) turns the Unreleased lines into version 3, dated today. The portal shows what will happen and asks you to confirm. Nothing goes live at this step. A release is refused when there is nothing under Unreleased. Versions are plain numbers: v1, v2, v3. They are easy to read and easy to compare. ## Deploy **Deploy v3 to prod** puts version 3 on our production server. The steps show as they run in the confirmation window: 1. We build the version on our build machine, never on the production server. 2. Only the files that changed travel to the server. 3. The server stops the old version, starts the new one, and checks that it answers and that it listens only where it must. 4. If any check fails, the server goes back to the previous version by itself, and the window shows the reason. The first deploy also gives your app its address. Before that, the header shows the address the app will have. ## Revert **Revert** puts the previous live version back. Revert again to go one more version back. The production server keeps the last three versions ready. An older one is built again from its saved code. ## Undeploy **Undeploy** stops the live app and takes its address down. Its data stays on the server. A later deploy brings the app back with its data as it was. ## What your users see After a deploy, each user sees a short **What's new** window at their next sign in: the versions released since their last visit, grouped by version, with one **Got it** button. A person who signs in for the first time sees nothing: there is nothing new for them yet. ## The header The header above the preview always shows the version in the working copy, the live version, its memory use, how long it has run, and its address. When the app is not live yet, it shows one button: **Deploy** for the newest release, or **Deploy to prod** when there is no release yet (it opens this tab). > **Note.** A demo app on sample data can be marked as never deployed. Release and deploy are then refused with the reason, and the app stays on the build machine. Source: https://golocomotive.fr/docs/using/releases/ # Users and access > Create the accounts of the people who use your app, set their role, switch them off, and copy them to the live app. The **Users** tab manages the people who can sign in to your app. It works on both copies of the app: **Dev**, the working copy in the preview, and **Prod**, the live app. The switch at the top picks one. Prod shows only once the app is live. ## Create a user Click **New user**. Give an email, a role, and a password: type one, or let the portal generate one. The portal shows the password **once**, with two buttons: **Copy password**, and **Copy the access text**. The access text holds everything the person needs: the app's name, its address, their email and their password. Send it to them the way you prefer. The portal keeps the password nowhere after you close the window. > **Note.** The access text names the address of each copy the account exists in, the live one first. A Dev account says that it works on the build machine only. ## Manage a user The list has a search box, filters on the role and on access, and pages. Each row's menu can: - set a **new password** (shown once, like a new account); - change the **role**; - **switch the account off**, or on again; - **delete** the account, after a confirmation that names it. Switching an account off ends its open sessions at once and refuses its next sign in. Its data stays. ## Copy Dev users to the live app While the app is live, a Dev row's menu has **Copy to prod**, and the toolbar has **Copy all to prod**. A confirmation says how many accounts it copies. An email that already exists in the live app is updated. The copy carries each password in its protected form, never in clear. A person signs in to the live app with the same password as in Dev. The result says how many accounts were created, updated, or failed. ## Who can reach the live app By default, anyone who knows the address reaches the sign in page, and the app's own sign in is the gate. You can also limit the live app to a list of internet addresses. See [App settings](https://golocomotive.fr/docs/using/settings.md#prod-address-and-access). ## Roles and rules What a role can see and change is part of your app, written as server rules by the agents from your requests. To change what a role can do, send a request: "staff see only the orders of their own site". See [Ask for a change](https://golocomotive.fr/docs/using/requests.md). Source: https://golocomotive.fr/docs/using/users/ # App settings > Your app's name, color, logo and language, and the live app's address and access, from the gear in the header. The gear in the app's header opens the settings in two parts: **The app** and **Prod**. ## The app: name, color, logo, language | setting | what it changes | |---|---| | **Name** | the name in the app's header, its browser tab and its emails | | **Color** | one color. Every shade of the app's theme comes from it | | **Logo** | an image in png, jpg, webp or svg. Without a logo, the app shows its first letter on its color | | **Language** | the language a visitor starts in: English or Français. Each user can switch in their profile menu | When you save, the change is saved in the app's own code, and the preview reloads by itself. It reaches the live app with the next release and deploy. A save is refused while an agent has unsaved work on that same file. Try again a moment later. ## Prod: address and access | setting | what it changes | |---|---| | **Subdomain** | the first part of the live app's address. It starts as the app's name | | **Access** | **Anyone with a login** (the default): anyone reaches the sign in page, and the app's own sign in is the gate. **Listed IPs only**: only the internet addresses in the list reach the app at all | | **Allowed IPs** | the list for **Listed IPs only**. Our own support address is always allowed as well | The live app takes these settings at its next deploy, or at once with **Apply**. Before the first deploy, the address reads "will be at" followed by the address, without a link. After it, it reads "live at" with the link. > **Tip.** **Listed IPs only** suits an app used from one office or a few known places. Remember to add a new address before you work from somewhere else. ## Where settings live The app's look lives in the app's code, so it travels with each version. The address and the access live with us, on the server side, so a change to them needs no new version. Source: https://golocomotive.fr/docs/using/settings/ # What an app can do > The parts every app can use from day one: data screens, accounts, the journal, files, email, automation, backups, two languages. Your app is made for your business, but it does not start from nothing. Every app sits on one shared toolkit, and the parts below are ready in it. An agent puts a part in your app when your request needs it, so you ask for the result ("export the order list") and not for the part. ## The parts | part | what your users get | page | |---|---|---| | Lists, records and forms | lists with search, filters, sort, pages and export, a page per record, forms that check what you type | [Lists, records and forms](https://golocomotive.fr/docs/apps/data.md) | | Sign in and accounts | password sign in, codes by email, a second step for sensitive roles, password reset, email change, alerts on a new device | [Sign in and accounts](https://golocomotive.fr/docs/apps/accounts.md) | | The journal | who changed what and when, with the before and after of each field, and every sign in | [The journal](https://golocomotive.fr/docs/apps/journal.md) | | Files and email | attachments on any record, private files, emails with attachments, mail templates in English and French | [Files and email](https://golocomotive.fr/docs/apps/files-and-mail.md) | | Automation | values computed on each change, tasks on a schedule, actions on a button, calls to other services | [Automation](https://golocomotive.fr/docs/apps/automation.md) | | Backups | a full copy of the app's data on a schedule, kept on the server or in a storage bucket, and a restore | [Backups](https://golocomotive.fr/docs/apps/backups.md) | | What's new | a short note at sign in with the changes since the user's last visit | [Release and deploy](https://golocomotive.fr/docs/using/releases.md#what-your-users-see) | | Your own AI | Claude and other MCP assistants work in the app as the user, under the same rules | [Connect your AI](https://golocomotive.fr/docs/connect.md) | ## Two languages The toolkit's own screens and emails exist in English and in French. A user picks the language in their profile menu, and the app's setting decides where a new visitor starts. Your own screens follow the language of your request: ask for a second language and the agent adds it. ## One rule set Every part goes through the same server rules: the screens, the journal, the exports, your own AI. A user who cannot see an order in a list cannot see it in an export, through the API, or through an AI either. ## What is not there yet Some things other platforms offer are not part of GoLocomotive today: phone apps from the app stores, chat apps such as WhatsApp or Slack, a marketplace of ready made apps. See [Not available yet](https://golocomotive.fr/docs/apps/planned.md). Source: https://golocomotive.fr/docs/apps/ # Lists, records and forms > How your data shows in the app: lists that the server filters and counts, a page per record, forms, related lists, safe deletes and exports. Most screens of a business app are lists of things and pages about one thing. The toolkit gives your app both, built from the shape of your data. ## Lists A list shows one kind of record (orders, clients, tickets) one page at a time. It has: - **Search** over the columns that make sense to search. Every word must match. - **Filters** on a column's values: pick one value or several. - **Sort** by a click on a column header. - **Pages** of up to 500 rows, and a count of all matching rows. - **Selection**: tick rows, or shift click to tick a range, then act on all of them. - **Export** of every row that matches the current search and filters (not only the page on screen) to a CSV file that any spreadsheet opens. The search, the filters, the sort and the count all run on the server. The browser receives one page at a time, never the whole table, and the count is always the real one. The list's search, filters and sort live in the page address. A bookmark or a link to a colleague opens the same view. ## A page per record A record page shows one record's fields with their labels, and the lists that belong to it: the orders of one client, the lines of one order. Each related list is a full list, with its own search and pages. ## Forms A form shows one input per field, in the order you asked for: text, numbers, yes or no switches, dates, a pick from a list of values, a pick of another record (the form searches as you type), and files. Required fields are checked before the form sends. If the server refuses a value, its reason shows under that field. A form saves only the fields you changed. ## Safe deletes Before a record goes, the app counts what still points at it. A client with orders cannot be deleted by mistake: the app says why, and the delete stops. Otherwise it asks you to confirm. ## Numbers you can trust A total, a count or a sum on a screen comes from the server, over the same filters as the list. When a number cannot be complete (a very large set the screen chose not to read in full), the app says so instead of showing a number that looks exact. ## Who sees what What a user can list, open, create, change or delete is decided by server rules, written for your app from your requests. The screens follow the rules. They never replace them. See [Trust](https://golocomotive.fr/docs/trust.md). Source: https://golocomotive.fr/docs/apps/data/ # Sign in and accounts > How people sign in to your app, how a second step protects sensitive roles, and what a user can change on their own account. Every app has its own accounts. A person who uses two apps has two accounts, one in each. You create them in the portal ([Users and access](https://golocomotive.fr/docs/using/users.md)), and the app handles the rest. ## Sign in - **Email and password.** The default. The app can also accept a second identifier, such as a user name. - **A code by email.** The app mails a short code, valid a few minutes, and the person types it. An app can offer it on its own or as a second step. - **A second step for sensitive roles.** The app can ask for a code after the password, for some roles only: an admin, for example. A person who typed the right password sees the code step, never a "wrong password" message. A session lasts 12 hours by default. After it, the person signs in again. ## Protection on the sign in page - Repeated attempts from one address are slowed down: 5 tries in 3 seconds, then a pause. - Codes accept 5 tries per account in 3 minutes. - A request for a password reset or a code answers the same way for an unknown email, so nobody can test which emails have an account. - **New device alerts.** When an account signs in from a new device or place, the app mails its owner, unless your app turns this off. ## What a user changes on their own account From the profile menu, each user can: - change their name; - change their password (they type the old one first, so a stolen open session cannot set a new password); - change their email address: the app mails a link to the new address, and the change happens only when the person opens it and types their password; - pick their language; - sign out. A password reset or an email change ends every open session of that account. ## Switch an account off An account switched off in the portal cannot sign in, and its open sessions end at once. Its data and its history stay. ## Emails the app sends Sign in emails (the code, the reset link, the email change link, the new device alert) exist in English and French. Your app can change their text, and adds its own footer to every mail. See [Files and email](https://golocomotive.fr/docs/apps/files-and-mail.md). ## Not available yet Sign in with Google, Microsoft or another outside account, and a check of the email address at sign up, are not part of the toolkit today. See [Not available yet](https://golocomotive.fr/docs/apps/planned.md). Source: https://golocomotive.fr/docs/apps/accounts/ # The journal > A record of every change and every sign in: who, when, from where, and each field before and after. The journal answers "who changed this, and when?". It is one list in your app, with one line per change, and it cannot be edited by anyone, admins included. ## What a line holds - **Who**: the account that made the change, with its name and email. - **When**, and **from where**: the internet address and the browser or device. - **What**: the kind of record, the record itself by its name, and the action (created, changed, deleted). - **The change**: for an edit, each field that changed, with its value before and after. For a new or deleted record, its fields. - **How**: through a screen, through a group of changes sent together, or through an AI connected to the app. A change made by your own AI is marked as such. See [Connect your AI](https://golocomotive.fr/docs/connect.md). A line is written only once the change is saved. A change the server refused leaves no line. ## Sign ins The journal can also record each sign in (with its method and device, for example "password · Firefox · macOS") and each refused attempt (with the identifier that was typed). Passwords and session keys are never written. ## Your own actions The app can add a line for its own actions: an invoice mailed, an export made. Ask for it when you want a trace of an action that changes no record. ## Reading the journal - The whole journal is a list like any other: search on the person and the record, filters on the action and the kind of record, export. - Each record page can show its own history: who, when, and a table of field, before and after. - Who can read the journal is a rule of your app. For example, admins only. It is never open to everyone, because it holds every account's activity. ## What it does not record Changes made by the app's own automations (a scheduled task, a value computed on save) leave no line by default: the line belongs to the person or the AI whose action started them. Source: https://golocomotive.fr/docs/apps/journal/ # Files and email > Attachments on records, private files behind short lived links, file storage on disk or in a bucket, and the emails your app sends. ## Files Any kind of record can hold files: a scan on an order, photos on a ticket, a contract on a client. A field holds one file or several. - **Upload** from the record's form: pick one file or several. Remove one with its button. - **Limits** per field: a maximum size (5 MB unless your app sets another) and the file types it accepts. - **Private files.** A field can be private. Its files open only through a link valid for a few minutes, made for a signed in user who is allowed to see that record. - **Where files live**: on the app's server disk, or in a storage bucket compatible with Amazon S3 (DigitalOcean Spaces, for example). The choice is per app. ## Email Your app sends mail through an email server you or we provide (SMTP). - **Sign in mails**: the code, the password reset link, the email change link, the new device alert. English and French versions are ready. Your app can change the text of each. - **Your own mails**: an invoice, a reminder, a report. They can carry attachments, such as a PDF the app made. - **One footer** for every mail the app sends, set once. The sender's name and address, and the app's name and address in links, are settings of your app. ## Documents An app can make documents (an invoice, a delivery note, a report) and offer them to print or download, or attach them to a mail. Ask for the document and its layout. The agent builds it in your app. Source: https://golocomotive.fr/docs/apps/files-and-mail/ # Automation > What your app does on its own: values computed on each change, checks before a save, tasks on a schedule, actions behind a button, calls to other services. In GoLocomotive, automation is part of your app's code. You describe what must happen, and the agent writes it as a small piece of the server. It runs in the app itself, every time, for every way a change arrives: a screen, an import, your own AI. ## Kinds of automation | you ask | what the agent writes | example | |---|---|---| | "keep this value up to date" | a step that runs after each saved change | the amount paid on an order, updated on each payment | | "refuse this when…" | a check before the save, or a server rule | an order cannot move back from delivered | | "every night, do…" | a task on a schedule | a reminder mail for open tickets older than 7 days | | "a button that…" | a server action the screen calls | mail the invoice of this order to its client | | "when a new client arrives, tell…" | a step after a create, that sends a mail or calls a service | a mail to the sales manager | | "fetch the rates from…" | a call to another service, on a schedule or on an action | daily currency rates from a public API | Schedules use your app's server clock in UTC: "every day at 23:00", "every Monday", "every hour". ## What makes it safe - **It runs on the server**, never in a user's browser, so it runs the same way for everyone. - **Checks run before the save**, side effects after it. A refused change triggers nothing. - **A failure is loud.** A scheduled task that fails writes the reason in the app's log, and the schedule goes on. - **It is reviewed like any change**: a card, a before and after when there is something to see, a line in the changelog when your users can notice it. ## Calling other services Your app can call any service that has a web API: an accounting tool, a delivery service, a payment provider, an AI model. The agent writes the call in your app. The service's key is a secret of your app's server, never in the screens. Your app can also receive calls: a custom web address that another system posts to, protected by a key or a sign in. > **Note.** There is no visual flow editor. Each automation is code in your app, written by an agent and checked by you. A change to it is a request like any other. Source: https://golocomotive.fr/docs/apps/automation/ # Backups > A full copy of your app's data at one moment, made on a schedule or on demand, kept on disk or in a storage bucket, and restored in about a second. A backup is one zip file that holds your app's whole data at one moment: the database, and the uploaded files when they live on the server's disk. ## How a backup is made - **One consistent moment.** The app copies its database and its files while it holds writes for an instant. Every file a record names is in the copy, and the app keeps serving during the backup. - **On a schedule**, for example every night. The app keeps the newest backups up to a number you choose and deletes the older ones. - **On demand**, by an admin of the app. - **Where they live**: on the server's disk, or in a storage bucket compatible with Amazon S3. A bucket keeps the backups outside the server. When the files themselves live in a bucket, they stay there, and the backup holds the database only. ## Restore A restore checks the backup first, while the app still serves: every entry is read and checked, and the database inside is opened and tested. Only then the app stops, saves what it replaces as one more backup, swaps the data in, and starts again, in about one second. If any check fails, nothing changes. A backup made by PocketBase restores too. See [Coming from PocketBase](https://golocomotive.fr/docs/developers/pocketbase.md). > **Note.** Backups run inside each app, and every app has them from its first day: a backup each night at midnight UTC, the newest 14 kept, on the server's disk unless the server names a storage bucket. A different schedule or count is a request: "a backup every 6 hours, keep the last 28". ## Not available yet A mail to the admins when a scheduled backup fails. Today the failure is written in the app's log. Source: https://golocomotive.fr/docs/apps/backups/ # Not available yet > What GoLocomotive does not do today, so you can plan around it. Each item is a plain no, not a promise of a date. This page lists what people often expect from a business platform and what GoLocomotive does not offer today. When one of these matters for your business, tell us: it helps us choose what comes next. ## The platform > **Planned.** None of the items below has a date. - **Phone apps from the app stores.** Your app works in the phone's browser, and its screens adapt to a phone. There is no app to install from an app store. - **A desktop program.** The app runs in the browser. - **Chat apps.** Your app does not talk through WhatsApp, Slack, SMS or phone calls. - **A marketplace of ready made apps.** Each app is built for one business. There is no catalogue of apps to install. - **AI credits.** Your own AI connects to your app and you pay its provider. GoLocomotive does not sell AI usage inside your app. - **A plain language endpoint.** A system without its own AI cannot send a sentence to your app and get the work done. It can call the app's web API. - **Outside MCP servers.** Your app is an MCP server for your AI. It does not call other MCP servers itself. ## Inside an app - **Live updates on screen.** A list updates after a change made in the same page. A change made by someone else shows after a refresh of the list. - **Sign in with an outside account** (Google, Microsoft and others). - **A check of the email address at sign up.** Accounts are created by you in the portal, so this rarely matters. - **Image thumbnails.** Images show at their full size. - **A visual flow editor.** Automation is code in your app. See [Automation](https://golocomotive.fr/docs/apps/automation.md). - **For your AI**: files, a list of the AI tools connected to an account, and revoking one connection without changing the password. See [Connect your AI](https://golocomotive.fr/docs/connect.md#not-there-yet). Source: https://golocomotive.fr/docs/apps/planned/ # Connect your AI > Your app as an MCP server. The AI you already use reads and changes your data as you, under the same rules as the screens. You already talk to an AI assistant: Claude, ChatGPT, or a coding tool. GoLocomotive lets that assistant work inside your app. It lists your orders, opens a client, creates a ticket, changes a status, as you do on a screen, and under the same rules. The link between the two is MCP, the Model Context Protocol: an open standard that AI assistants use to call tools in other systems. Your app can act as an MCP server. Your assistant is the client. ## What it looks like > Which orders of the north site are late this week? Move the ones from regular clients to in progress, and list the others. The assistant calls your app's tools: a filtered list of orders, then one update per order. Each update goes through the same checks as a click on a screen. The journal records each change with the mark that an AI made it. ## Three promises 1. **It acts as you.** The assistant signs in with your account. It sees what you see and can do what you can do, nothing more. An account that cannot delete an order on a screen cannot delete it through the assistant either. 2. **The AI is yours.** You pick the assistant and you pay its provider. GoLocomotive spends no AI on your behalf for this. 3. **Your app can say no to the AI.** A rule can refuse an AI an action that a person can do on a screen, for example "an AI never deletes a payment". See [Keep your AI in bounds](https://golocomotive.fr/docs/connect/control.md). ## Turn it on The MCP server is off until your app turns it on. Ask for it in a request: "connect our app to Claude". The agent turns it on, chooses with you what the assistant can see, and the change goes live with your next deploy. Then connect your assistant: [Set up your AI client](https://golocomotive.fr/docs/connect/setup.md). ## What has been tested - Claude Code (Anthropic's coding assistant) has worked in an app through its MCP server as a signed in user. The app's rules and its journal applied to every call. - The sign in flow that assistants use (OAuth 2.1) works with the official MCP client libraries. - claude.ai, the Claude desktop app and ChatGPT connect through that same sign in flow. They need the app's public address, and no live app has been connected to them yet. Until then, treat them as the next step, not a tested path. ## Not there yet - Files: the assistant cannot upload or download a file field. Files go through the app's screens. - A page that lists the assistants connected to your account, and a button to revoke one. Today a password change ends every connection. - A code by email or a second step on the assistant's sign in page. An account protected by a second step signs in to the app first, then returns to the assistant's sign in. ## Pages in this section - [Set up your AI client](https://golocomotive.fr/docs/connect/setup.md) - [What your AI can do](https://golocomotive.fr/docs/connect/tools.md) - [Keep your AI in bounds](https://golocomotive.fr/docs/connect/control.md) - Developers: [The MCP server](https://golocomotive.fr/docs/developers/mcp.md) and [MCP protocol and OAuth](https://golocomotive.fr/docs/reference/mcp-protocol.md) Source: https://golocomotive.fr/docs/connect/ # Set up your AI client > Connect Claude Code with a token, or a connector such as claude.ai or ChatGPT through your app's own sign in page. Your app's MCP server lives at one address: your app's address followed by `/mcp`. On this page, `https://orders.example.com` stands for your app's address. There are two ways in. A coding assistant or a script uses a **token**. A connector in a chat app (claude.ai, the Claude desktop app, ChatGPT) uses your app's **sign in page**, the standard OAuth flow. ## Claude Code, with a token 1. Get a token by signing in to the app's API with your email and password. A developer on your team can do it with one request: ```sh curl -s https://orders.example.com/api/collections/users/auth-with-password \ -H 'content-type: application/json' \ -d '{"identity":"you@example.com","password":"your password"}' ``` The answer holds `"token": "…"`. 2. Add the server to Claude Code: ```sh claude mcp add --transport http orders https://orders.example.com/mcp \ --header "Authorization: Bearer " ``` 3. Ask Claude Code what it can do in `orders`. It lists your app's tools. The token is your session: it lasts as long as a session of your app (12 hours unless your app sets another length), and a password change ends it. Treat it like a password. ## A connector, through your app's sign in For claude.ai, the Claude desktop app, ChatGPT and other clients that support remote MCP servers with OAuth: 1. In the client, add a custom connector (the name of the menu differs per client) with the address `https://orders.example.com/mcp`. 2. The client opens your app's own sign in page in a window. Sign in with your app account, as on the app. 3. The page asks: "Allow to act as ?". Accept. 4. The client receives a key valid one hour, renewed by itself for up to 30 days of use. You never copy a token. A password change or an email change ends every connection at once: the client must sign in again. > **Note.** This path follows the MCP standard and works with the official MCP client libraries. A connector of claude.ai or ChatGPT needs your app's public address, and none has been tested against a live app yet. See [What has been tested](https://golocomotive.fr/docs/connect.md#what-has-been-tested). ## Other clients Any client that speaks MCP over HTTP can connect: with OAuth when it supports it, else with a bearer token in the `Authorization` header as for Claude Code. Your app speaks the current MCP version and the three before it. ## If it does not connect | the client says | what it means | |---|---| | 401, unauthorized | no token, or the token ended. Get a new one, or sign in again | | 403, forbidden | the account is a superuser, the technical root account of the app (refused by default), or the request came from a web page the app does not trust | | a tool answers with an error | the app refused that action: a rule, a missing record, a value it does not accept. The message says which | | too many requests | more than 60 changes in one minute from your account. Wait a minute | | no MCP server at that address | the MCP server is not turned on in the live version of your app. Ask for it, then deploy | Source: https://golocomotive.fr/docs/connect/setup/ # What your AI can do > The tools your app gives an AI: the same seven for any data, made to read your data's shape, plus the actions your app adds. Your assistant does not see screens. It sees **tools**: named actions with a description and the inputs they take. Every app gives the same few tools for its data, plus the actions your app adds. A new field or a new kind of record shows up as soon as it is deployed. ## The tools for your data | tool | what it does | |---|---| | `schema` | tells the assistant which kinds of records it may use, their fields, and what it may do with each | | `list` | list the records of one kind: a filter, a sort, a page, the fields to return, related records to include | | `get` | open one record by its id | | `create` | create a record from its fields | | `update` | change some fields of one record | | `delete` | delete one record | | `batch` | several creates, changes and deletes at once: all of them happen, or none | The assistant calls `schema` first. It answers in a few lines: for orders, the fields with their types, their allowed values and what they point to, and the actions the account may run. The assistant then names the kind of record in each call: `list` with `orders` and a filter. The assistant sees only what its account can use. A kind of record that only the app's root account can read does not appear, and every tool refuses it as if it did not exist. A computed list (a summary, a report the app defines) offers `list` and `get` only. ## Your app's own actions An action your app has beyond plain records ("mail the invoice of this order", "close the month") can be a tool too. Ask for it: "let the AI send the invoice". The agent adds the tool with a clear description and marks whether it changes anything. ## How the assistant reads an answer - A tool answers with the data, as the app's web API does. - A refused call is an answer too, marked as an error, with the reason: "Only superusers can perform this action", a missing record, a field that does not accept that value. The assistant reads it and can tell you why. - Read tools are marked read only. `delete` and `batch` are marked as destructive. A careful client asks you before it calls a destructive tool. ## What the assistant never gets - Hidden fields, passwords and session keys. - The rules themselves: it sees what it may do, never how the app decides. - Files. They go through the app's screens. - What your app hides from AI on purpose. See [Keep your AI in bounds](https://golocomotive.fr/docs/connect/control.md). Source: https://golocomotive.fr/docs/connect/tools/ # Keep your AI in bounds > The limits an AI meets in your app: the account's rights, rules that refuse an AI, hidden tools, a write limit, and the journal. An AI that works in your app is one more user, with the rights of the account it signed in with. These limits stack on top of each other. ## 1. The account's rights Every tool call goes through the same path as a click on a screen: the same server rules, the same checks before a save, the same automations after it. The assistant can never do more than its account. ## 2. Rules that refuse an AI A server rule can tell a person from an AI. For example: managers can delete a payment on a screen, but never through an AI. The rule stays one line in your app's data rules. Ask for it in plain words: "an AI never deletes a payment". ## 3. Hidden tools Your app can hide a whole kind of record from AI (the journal, for example), or one action on it (deleting users, for example). A hidden kind of record is not in what `schema` tells the assistant, and every tool refuses it as if it did not exist. ## 4. A write limit Changes through AI are limited per account: 60 per minute by default. Past it, the tool answers "too many requests" and the assistant waits. Your app can set another limit. ## 5. The root account stays out The app's superuser, its technical root account, cannot use the MCP server unless your app allows it on purpose. ## 6. The journal marks AI changes Each change an AI makes is a journal line like any other, with the account, the time and each field before and after, plus the mark that it came through MCP. See [The journal](https://golocomotive.fr/docs/apps/journal.md). ## 7. Connections end with the password A connector's access lasts one hour and renews itself for up to 30 days. A password change or an email change ends every connection of that account at once. A token used by Claude Code ends with the session it came from. ## Briefing the assistant Your app can give every assistant a short paragraph about itself: what the app is for, its units, its words. For example "Orders of a shop. Amounts are in euros, without tax." It saves the assistant guesses and you corrections. Source: https://golocomotive.fr/docs/connect/control/ # Trust > Where your app runs, what protects it, where your data lives, and what keeps the agents inside their limits. You trust GoLocomotive with your business data and with code that AI agents write. This section says plainly what protects both, and what is not done yet. ## In short - **Your app runs alone.** One process and one database file per app, on servers we run. No other customer's code or data is in it. - **Nothing reaches the app directly from the internet.** The app listens only inside its server. Visitors reach it over HTTPS through one front door that carries only its own address. - **Your rules are the last word.** Who can read or change each record is decided by the app's server rules, for every screen, every export and every AI. - **Agents work in a closed space.** They change only your app's code, reach only the services they need, and stop at a spending limit. - **Nothing ships without you.** Agents change the working copy. Only you release and deploy. - **Your app is yours.** Its code and data are yours from day one. You get the full repository on request, and you leave with it whenever you want. ## Pages in this section - [Security](https://golocomotive.fr/docs/trust/security.md): the layers between the internet and your data. - [Your data](https://golocomotive.fr/docs/trust/data-protection.md): where it lives, who can see it, backups, and how your code and data leave with you. - [How agents are contained](https://golocomotive.fr/docs/trust/agents.md): what an agent can and cannot reach while it changes your app. Source: https://golocomotive.fr/docs/trust/ # Security > The five layers between the internet and your app's data, from the network edge to the rule on each record. Your live app runs on a production server that we own and run, never on a client machine. Five layers stand between a visitor and your data. Each one works even if the one before it fails. ## 1. The app listens only inside its server The app accepts connections only from its own server, never from the network. After each start, the server checks what the app listens on. A version that listens anywhere else is refused and the previous version comes back within seconds. ## 2. One locked down system user per app Each app runs as its own system user, with no shell, no home folder and no administrator rights, and it cannot gain new rights. That user owns only the app's data folder, which no other app can read. The app's code is read only to it. The program that starts and stops apps has the narrow rights it needs for that, and nothing more. ## 3. One front door The server's firewall opens the web ports only: no app has a port of its own on the internet. One web server in front receives every visit, over HTTPS, and passes each app's address to that app alone. A request for any other address gets nothing, and gets no certificate. Each app's certificate comes from Let's Encrypt and renews by itself. ## 4. Access at the edge Each app has an access setting, applied by the front door before a request reaches the app: - **Anyone with a login** (the default): anyone reaches the sign in page. The app's own sign in is the gate. - **Listed IPs only**: only the internet addresses on the app's list reach it at all. Everyone else is blocked at the front door. See [App settings](https://golocomotive.fr/docs/using/settings.md#prod-address-and-access). ## 5. The app's own sign in and rules Behind all of it, the app itself decides: - Sign in with a password checked against a strong one way hash (bcrypt), sessions that end after 12 hours by default, an optional code by email or a second step for sensitive roles, and a slowdown after repeated attempts. See [Sign in and accounts](https://golocomotive.fr/docs/apps/accounts.md). - A server rule on each kind of record and each action: list, view, create, change, delete. A rule the app does not write means "nobody but the root account". Every value in a filter is passed to the database as a value, never as code. - Private files open only through short lived links, after the record's own rule. ## Deploys that cannot break the live app A new version is built on our build machine, then checked on the server: it must answer its health check and listen only inside the server. If either check fails, the previous version comes back by itself. See [Release and deploy](https://golocomotive.fr/docs/using/releases.md#deploy). ## Not done yet - Certifications such as SOC 2 or ISO 27001. We have none today. - Backups of the live apps run inside each app, when the app turns them on. See [Backups](https://golocomotive.fr/docs/apps/backups.md). Source: https://golocomotive.fr/docs/trust/security/ # Your data > Where your app's data lives, who can reach it, how it is backed up, and how you take it with you. ## Where it lives Your live app's data is one database file and one folder of uploaded files, on our production server, in a folder only your app's system user can open. Files can also live in a storage bucket of your choice, compatible with Amazon S3. The working copy that agents change runs on our build machine, with its own separate data. Agents work there only. They have no access to the production server or to your live data. ## Who can reach it | who | reaches | how | |---|---|---| | your users | what their role allows | the app's screens, through its server rules | | your own AI | what the signed in account allows | the app's MCP server, through the same rules | | the app's root account | everything in the app | used by our portal to manage your users at your request | | our agents | the working copy only, never the live data | see [How agents are contained](https://golocomotive.fr/docs/trust/agents.md) | | other customers | nothing | each app is a separate process, database and system user | ## What the app keeps about people - Accounts: email, name, role, a one way hash of the password (never the password itself). - The devices an account signed in from, to send new device alerts. A user can list and remove theirs. - The journal, when your app turns it on: who changed what, with the internet address and browser of each change. - Nothing else unless your app's data holds it. Your app holds what you ask it to hold. ## Backups Backups run inside the app, on a schedule and with a store you choose (the server's disk or a bucket). Each one is a full, consistent copy of the data at one moment. See [Backups](https://golocomotive.fr/docs/apps/backups.md). ## Take it with you Your app's code and data are yours from day one. You get the full repository on request, at any time, and you leave with it whenever you want. The locosdk version your app runs comes with a perpetual license. - **The code** is a full repository: every file of your app and its whole history of changes. - **Lists** export to CSV, filtered as you like. - **The whole database** is a standard SQLite file in the open PocketBase layout. PocketBase, an open source backend, opens it as it is. - **Backups** are zip files of the data folder, in the same format PocketBase uses. ## When the app goes offline **Undeploy** stops the live app and takes its address down. Its data stays on the server, so a later deploy brings it back as it was. ## Not published yet - A data processing agreement and the legal terms of the service. - The name of our hosting provider and the region of the production server. Source: https://golocomotive.fr/docs/trust/data-protection/ # How agents are contained > What an agent can and cannot reach while it changes your app, how its spending stops, and what it can never do. An agent is an AI coding assistant that runs on our build machine. It works on one request in one app. These limits hold for every run. ## A closed space Each run starts inside the operating system's own sandbox. A run does not start without it. - **Files.** The agent reads and writes your app's code folder. It cannot read the other apps, nor the folders where we keep their data and our own. - **Network.** It reaches two services: the package registry it installs code from, and the AI provider that runs its model. Every other address is refused. - **Commands.** Every command it runs stays inside the sandbox. A command the rules do not allow is refused, never asked about. - **Secrets.** The key to the AI provider lives in the agent's own process only. It is removed from every command the agent runs, and it never appears in a log. ## A spending limit Each run is measured as it goes, at the model's public price. A run that reaches its limit (one US dollar by default) is stopped at once. A model without a public price is refused before it starts. The card of each run shows what it spent. ## What an agent cannot do - **Release or deploy.** Only you do that, from the portal. - **Approve its own work.** Only your answer approves a card. - **Wait in a chat.** It asks on the card and stops. Nothing runs while it waits for you. - **Reach your live app.** It works on the working copy and its separate data. ## What an agent must do The rules every agent follows are written down and shipped with the toolkit, so they always match the toolkit version your app uses. Among them: - describe a change in the app's own notes before it writes the code; - run the app's checks (it compiles, the tests pass, the code style holds) before it shows you anything; - write one changelog line for each change your users can see; - show every visible change as a card with a before and an after; - save its work in the app's history before it sends the card, so nothing is lost if a run stops. See [The agent kit](https://golocomotive.fr/docs/extend/agent-kit.md). ## What is not contained yet The working copy of an app runs on the build machine as our own system user, and it runs code an agent wrote. It is not in the agent's sandbox, and other local services of the build machine are reachable from a run. One system user per working copy is the next layer. It is not built yet. The live apps on the production server already run this way: see [Security](https://golocomotive.fr/docs/trust/security.md). Source: https://golocomotive.fr/docs/trust/agents/ # Extend your app > Bigger changes: new kinds of data, new screens, new actions, links to other services. How to ask, and what your app is made of. On other platforms, you extend an app with add ons from a store, or with a small panel that runs inside someone else's system under strict limits. In GoLocomotive, your app is its own program. A bigger change is a request like any other, and the agent changes the app itself: its data, its screens, its server. ## What your app is made of | part | what it holds | you ask for | |---|---|---| | the schema | each kind of record, its fields, its links to other records, its rules | "a client has several sites", "staff see only their site" | | summaries | computed lists over your data: totals per month, late orders per site | "a page with the revenue per month and per site" | | the screens | lists, record pages, forms, dashboards, documents | "a dashboard for the morning meeting" | | the server | checks before a save, steps after it, scheduled tasks, actions behind a button, calls to other services | "mail the invoice when an order is delivered" | | the AI tools | what your own AI can do in the app | "let the AI create tickets but never delete them" | Because each app runs alone, nothing limits it to a sandbox inside a shared system: it has real database columns and indexes, server code, and a full screen framework. The limits are your rules and your review. ## How a bigger change goes 1. **Describe the result** and who uses it. A short paragraph, as in [Your first week](https://golocomotive.fr/docs/start/first-week.md). 2. **Answer the agent's questions.** For a new kind of data, expect one or two: what is required, who sees it. 3. **Judge in steps.** A bigger change comes as several cards: the data and its list first, then the screens, then the automations. 4. **Release when the whole is ready.** Approved cards wait under Unreleased until you release. ## A change to existing data Adding a field or a new kind of record is safe: the app adds it at its next start. Removing or renaming a field that holds data, or changing its type, needs a written migration step. The app refuses to start without one, so data is never dropped by accident. The agent writes the step, and it shows in the card. ## Pages in this section - [What to ask for](https://golocomotive.fr/docs/extend/ideas.md): four example requests and what the agent builds for each. - [Connect other services](https://golocomotive.fr/docs/extend/integrations.md): accounting tools, payment providers, mail, storage, webhooks. - [The agent kit](https://golocomotive.fr/docs/extend/agent-kit.md): the rules our agents follow in every app. Source: https://golocomotive.fr/docs/extend/ # What to ask for > Four example requests, from a sales pipeline to equipment booking, and what the agent builds in your app for each one. Each example below is a request you can send as it is, followed by what the agent builds. The parts named on the right are described in [What an app can do](https://golocomotive.fr/docs/apps.md). ## A sales pipeline > We follow leads from first call to signed deal. A lead has a company, a contact, a value and a stage (new, contacted, proposal, won, lost). Sales people see their own leads, the manager sees all of them. I want the total value per stage. - A `leads` kind of record with its fields, the stage as a fixed list of values, and a link to the sales person. - A rule: a sales person lists and changes only their own leads. The manager sees all. - A summary that adds up the value per stage, computed by the database. - A list with filters on the stage and the sales person, and a board view by stage if you ask for one. - AI tools: your assistant lists and updates leads through the app's tools, so it can answer "which proposals are older than two weeks?". ## An invoice tracker > We send invoices to clients and follow payments. An invoice has lines, a total, a due date and a status. Show me what is overdue, and mail the invoice to the client from its page. - `invoices` and `invoice_lines`, linked, with a total kept up to date on the server each time a line changes. - A summary of overdue invoices per client. - A record page with its lines, a **Mail the invoice** button that makes the PDF and sends it with the app's mail template. - A nightly task that marks invoices past their due date as overdue. - A journal line for each invoice mailed. ## Equipment booking > We lend equipment to our teams. A booking has an item, a person, a start and an end. Two bookings of the same item must not overlap. Show the bookings of a day. - `equipment` and `bookings`, with a check on the server that refuses an overlapping booking and says which booking is in the way. - A day view of the bookings, and a list per item. - An AI tool that answers "is the large drill free on Friday?" from the bookings list. ## A client knowledge base > Our clients read help articles in the app. Admins write them. Clients only read. Articles have a category and can be searched. - `articles` with a category, a body and a published switch. - Rules: clients list and read published articles only. Admins create, change and delete. - A searchable list for clients, and the full list with editing for admins. ## After the first version Each of these grows by requests: a new field, an export, a reminder mail, a second language. Each request comes back as its own card. Source: https://golocomotive.fr/docs/extend/ideas/ # Connect other services > How your app talks to other systems: outgoing calls to any web API, incoming webhooks, mail through your own server, files in your own bucket. Your app is a server program, so it can talk to any service that has a web API. There is no fixed list of connectors: the agent writes the link your app needs. ## Outgoing: your app calls a service | when | example | |---|---| | after a change | a delivered order is pushed to your accounting tool | | on a schedule | each night, the day's sales go to a spreadsheet service, or exchange rates come in | | on a button | "Send to the carrier" creates a shipment and stores its tracking number | Ask with the service's name and what must travel: "when an invoice is paid, mark it paid in our accounting tool". The agent asks for the service's documentation when it needs it. ## Incoming: a service calls your app Your app can have its own web addresses that another system posts to: a payment provider confirming a payment, a form on your website, a device sending a reading. Each address checks who calls it (a shared key, a signature, or a signed in account), because the address itself is the access control. ## Keys and credentials A service's key is a setting of your app's server. It never appears in the screens, the code history or the logs. ## Mail Your app sends mail through an SMTP server: yours, or a mail service you use. The app's own sign in mails and your mails go through it, with one footer for all. See [Files and email](https://golocomotive.fr/docs/apps/files-and-mail.md). ## Files Files and backups can live in a storage bucket compatible with Amazon S3 that you own, instead of on our server's disk. ## Your own AI The other direction: your AI assistant calls your app. See [Connect your AI](https://golocomotive.fr/docs/connect.md). > **Planned.** Your app does not call other MCP servers itself, and it has no plain language endpoint for systems without an AI. See [Not available yet](https://golocomotive.fr/docs/apps/planned.md). Source: https://golocomotive.fr/docs/extend/integrations/ # The agent kit > The written rules every agent loads before it touches an app, shipped with the toolkit so they always match it. Every app's code holds a short instruction file that the agent reads first. It loads two sets of pages: the **agent kit**, which comes with the toolkit, and the **app's own notes**, which describe your app. Where the two disagree, the app's notes win. The kit is versioned with the toolkit. When the toolkit changes an API, the page that describes it changes in the same version. An agent never works from documentation that is older than the code it calls. ## What the kit holds | page | covers | |---|---| | conduct | how to work: one subject per card, ask on the card, never in a chat, never fail silently | | code | TypeScript rules: strict types, no shortcuts that hide errors, one way to do each thing | | tests | what a test checks, and how fast the tests must stay | | state | how screens hold their data | | screens | lists, forms, grids: what the toolkit gives, and what not to rebuild | | data | every list is filtered, sorted and counted by the server. Never download a whole table to count it | | languages | user facing text in the app's languages, technical text in English | | AI calls | how app code calls an AI model, when an app needs one | | one page per API | the schema, the rules, the client, the screens, review cards, pictures of the app, the changelog, the MCP server | ## The rules that matter most to you - **Notes before code.** The agent updates the app's notes first, then writes the code those notes describe. The notes stay a true description of your app. - **The server decides.** Rules live on the server. A check in a screen is a convenience, never the protection. - **Checks before you see it.** The agent runs the app's checks (types, tests, code style) before it sends a card. - **One changelog line per visible change**, written for your users in their language. The checks fail if code changed and the changelog did not. - **Before and after.** Every visible change is a card with a picture before and after, at the same size. The agent opens both pictures itself before it sends them. - **Nothing approved by itself.** Only you approve. - **Release is yours.** An agent never releases unless your request asks for it. ## The app's own notes Each app has an `agent` folder: what the app is for (your first description, quoted), its data, its screens, its decisions. The agent keeps it up to date with each change. It is the first thing a new agent reads, so a request two months later starts from what the app is today. ## For developers The kit's pages are the basis of the [Developers](https://golocomotive.fr/docs/developers.md) section of these docs. Source: https://golocomotive.fr/docs/extend/agent-kit/ # Developers > How a GoLocomotive app is made: locosdk, schema.ts, the server, the client, the UI and the golo cli. Every GoLocomotive app is one TypeScript repo on one dependency, `locosdk`. The app holds its schema, its pages and its hooks. Everything generic lives in the sdk: the database, the API, sign in, the data layer, the UI shell, the review cards and the release tools. This section is for a developer, or an agent, who reads or changes an app. The [Reference](https://golocomotive.fr/docs/reference.md) holds the full tables. ## One process per app An app runs as one Bun process. That process serves the API, the pages, a small admin and one SQLite file. There is no shared database and no tenant column: each app has its own data folder. The storage, the REST wire and the filter language copy PocketBase. A PocketBase database opens as is, and a model that knows PocketBase already knows the API. ## The tiers `locosdk` exports four tiers and a cli. A lower tier never imports a higher one. | import | what it owns | |---|---| | `locosdk/schema` | field builders, `defineSchema`, the inferred record types, the audit journal collection, the mail templates | | `locosdk/server` | `createApp`: SQLite in PocketBase's layout, the rule compiler, the records API, auth, files, hooks, crons, mail, backups, the MCP server | | `locosdk/client` | the typed fetch client and the MobX data layer: lists, records, aggregates, batched writes | | `locosdk/ui` | the Mantine shell, sign in, grids, record cards, the schema form, the journal screens, the what's new modal | | `golo` | the cli in the app repo: gate, review cards, shots, releases, backups, schema import, superusers | ## Two rules hold everything 1. **The server does the work.** Filtering, sorting, paging, relations and sums run on the server. A screen never downloads a collection to filter, count or sum it. The data layer classes make this the easy path. 2. **Server rules are the law.** Each collection declares who can list, view, create, update and delete, in the filter language. The records API applies them on every request. A check in the UI only helps the user. ## In this section | page | read it when you | |---|---| | [An app, step by step](https://golocomotive.fr/docs/developers/quickstart.md) | start an app or want the whole picture in one place | | [Anatomy of an app](https://golocomotive.fr/docs/developers/anatomy.md) | look for a file, or need the contract with the host | | [Schema as code](https://golocomotive.fr/docs/developers/schema.md) | add a collection, a field, an index or a view | | [Rules and filters](https://golocomotive.fr/docs/developers/rules.md) | decide who sees and changes what | | [Server: hooks, routes, crons](https://golocomotive.fr/docs/developers/server.md) | add behavior on the server | | [Client and data layer](https://golocomotive.fr/docs/developers/client.md) | read or write data from a page | | [UI: shell, grids, forms](https://golocomotive.fr/docs/developers/ui.md) | build a screen | | [Auth and mail](https://golocomotive.fr/docs/developers/auth.md) | change sign in, codes, mails | | [Files, backups and proxies](https://golocomotive.fr/docs/developers/files-backups.md) | store files, back up data, run behind a proxy | | [The MCP server](https://golocomotive.fr/docs/developers/mcp.md) | let a client's own AI work in the app | | [Review cards and shots](https://golocomotive.fr/docs/developers/review.md) | show a change to the client | | [Changelog and releases](https://golocomotive.fr/docs/developers/releases.md) | ship a version | | [Coming from PocketBase](https://golocomotive.fr/docs/developers/pocketbase.md) | move a PocketBase app | Source: https://golocomotive.fr/docs/developers/ # An app, step by step > The smallest complete app: a schema, a server, a page with a list of orders, and a sign in. This page builds an app with clients and their orders. Each step is a file of the app repo. A new app starts from a template with the same files, so this is also a tour of what an agent finds on its first day. ## 1. The schema `schema.ts` is the single source of truth for data. It declares the collections, their fields and their rules. The types of every record come from it, with no code generation. ```ts import { auth, base, defineSchema, number, relation, select, text } from 'locosdk/schema' const signedIn = '@request.auth.id != ""' const rules = { listRule: signedIn, viewRule: signedIn, createRule: signedIn, updateRule: signedIn, deleteRule: signedIn } export const schema = defineSchema({ users: auth({ fields: { name: text(), role: select(['admin', 'agent']) }, listRule: 'id = @request.auth.id', viewRule: 'id = @request.auth.id', updateRule: 'id = @request.auth.id', }), clients: base({ fields: { name: text({ required: true, presentable: true }), city: text() }, ...rules }), orders: base({ fields: { client_ref: relation('clients', { required: true }), label: text({ required: true }), status: select(['open', 'done']), amount: number(), }, ...rules, }), }) ``` A rule left out is `null`: only a superuser passes it. More: [Schema as code](https://golocomotive.fr/docs/developers/schema.md), [Rules and filters](https://golocomotive.fr/docs/developers/rules.md). ## 2. The server `server.ts` makes the app and serves it. `createApp` opens `data/app.db`, brings its tables in line with the schema, and mounts the PocketBase API, the pages and `GET /api/health`. ```ts import { createApp } from 'locosdk/server' import config from './loco.config.ts' import { schema } from './schema.ts' import index from './src/index.html' const app = createApp({ schema, app: config.app, pages: { '/*': index } }) app.serve() ``` `serve()` listens on `PORT`, else 8090. Hooks, routes and crons go between the two lines: [Server: hooks, routes, crons](https://golocomotive.fr/docs/developers/server.md). ## 3. The identity `loco.config.ts` names the app. It is plain data: the portal's settings rewrite the `app` block in place. ```ts import type { LocoConfig } from 'locosdk/src/config/config.ts' export default { app: { name: 'Orders', color: '#0b7285', logo: null, lang: 'en' }, } satisfies LocoConfig ``` The theme derives every shade from `color`. `lang` is `en` or `fr`: the language a visitor starts in. ## 4. The data layer `src/data.ts` holds the app's one client, its observable database and its session. ```ts import { createClient, createDb } from 'locosdk/client' import { notify, Session } from 'locosdk/ui' import { schema } from '../schema.ts' export const client = createClient(location.origin, { schema }) export const db = createDb(client, { notify }) export const session = new Session(client) ``` `db.orders` is typed from the schema. A name outside the schema fails the typecheck. More: [Client and data layer](https://golocomotive.fr/docs/developers/client.md). ## 5. A collection on screen `defineCollectionUI` describes a collection once. The grid, the record card, the form and the export all read it. ```tsx import { defineCollectionUI } from 'locosdk/ui' import { db } from '../../data.ts' export const clients = defineCollectionUI(db.clients, { title: 'Clients', label: (c) => c.name, sort: 'name', form: { fields: ['name', 'city'] }, columns: [ { id: 'name', label: 'Name', value: (c) => c.name }, { id: 'city', label: 'City', value: (c) => c.city }, ], }) ``` ## 6. The page `src/main.tsx` mounts the shell. `AppRouter` gives the sign in page, the guard, the navbar and a not found page. ```tsx import 'locosdk/ui/setup' import { createRoot } from 'react-dom/client' import { AppRoot, AppRouter, LiveGrid } from 'locosdk/ui' import { clients } from './collections/clients/index.tsx' import { client, session } from './data.ts' import config from '../loco.config.ts' createRoot(document.body.appendChild(document.createElement('div'))).render( }]} /> , ) ``` `locosdk/ui/setup` is always the first import: it installs the globals `X` (Mantine), `H` (Mantine hooks) and `I` (icons). ## 7. Run it ```sh bun install bun --hot server.ts # in a second terminal, once the app booted bunx golo superuser upsert admin@example.com ``` The first boot creates `data/app.db`. `superuser upsert` reads the password on stdin. `golo` is the bin of `locosdk`: `bunx golo` runs it from the app's own install. Open the app on port 8090, sign in, and the Clients grid pages, sorts, searches and exports through the server. ## 8. Check it ```sh golo gate ``` `golo gate` runs the typecheck, the tests and the lint in parallel and exits 1 on red. It also fails when the code changed and `CHANGELOG.md` has no line for it: [Changelog and releases](https://golocomotive.fr/docs/developers/releases.md). Source: https://golocomotive.fr/docs/developers/quickstart/ # Anatomy of an app > The files of an app repo, what each one owns, and the small contract between an app and the host that runs it. An app is one git repo. It stays small: the schema, the pages, the hooks, its own spec. Everything generic comes from `locosdk`. ## The repo ```text / CLAUDE.md two lines: the kit, then the app's own spec agent/ the app's spec, read by every agent, wins over the kit schema.ts collections, fields, rules: the single source of truth server.ts createApp({ schema, app, pages }), hooks, routes, crons, then serve() loco.config.ts the identity (name, color, logo, lang), review topics, deploy: false CHANGELOG.md Unreleased, then v sections: what the app's users read src/ main.tsx, data.ts, collections//, pages tests/ bunfig.toml the test preload: fast password hashing in tests only data/ gitignored, your data only: app.db, uploaded files, backups .loco/ ignores itself: the builders' workspace, review cards, shots ``` | file | owns | rule | |---|---|---| | `schema.ts` | every collection, field, index, view, rule and auth option | a change here is applied at the next boot, see [Schema as code](https://golocomotive.fr/docs/developers/schema.md) | | `server.ts` | hooks, custom routes, crons, mail, MCP | plain TypeScript in the app's process | | `loco.config.ts` | the identity, review topics, `deploy: false` | plain data only: no import, no expression. It is read as text and rewritten in place by the portal | | `CHANGELOG.md` | what the users read in the what's new modal | one line per visible change, see [Changelog and releases](https://golocomotive.fr/docs/developers/releases.md) | | `agent/` | the app's own spec: what it is for, its screens, its decisions | updated before the code it describes | ## CLAUDE.md and the agent kit `CLAUDE.md` holds two lines: ```md @node_modules/locosdk/agent/index.md @agent/index.md ``` The first line loads the agent kit that ships inside `locosdk`: conduct, code rules, and one page per sdk surface. The kit is versioned with the sdk, so the docs an agent reads always match the API it calls. The second line loads the app's own spec, which wins on conflict. More: [The agent kit](https://golocomotive.fr/docs/extend/agent-kit.md). ## The data folder `data/` is gitignored and holds your data, nothing else. The agents that change the app cannot read or write it: their sandbox closes the folder. | path | what | |---|---| | `data/app.db` | the SQLite database, WAL mode, PocketBase's layout | | `data/storage/` | uploaded files, `//`, unless the app stores files on S3 | | `data/backups/` | backups on disk, unless the app keeps them on S3 | `.loco/` is the workspace of whoever builds the app, kept out of git by its own `.gitignore`: | path | what | |---|---| | `.loco/review.db`, `.loco/review//` | review cards, their threads, their images and clips | | `.loco/shots/` | shots of the app taken by `golo shot` | ## The contract with the host The host that runs an app asks two things of it: 1. Listen on the port in `PORT`. `app.serve()` does it, and uses 8090 when `PORT` is not set. 2. Answer `GET /api/health` with 200. `createApp` mounts it. Nothing else. In production an app binds the loopback interface only, and the server's front door passes it its own visitors. See [Security](https://golocomotive.fr/docs/trust/security.md). ## One process - One Bun process serves the API, the pages (Bun's HTML imports, hot reload in dev) and the database. - Hooks and crons run in that process as plain TypeScript. No VM, no sandbox, no sleep between requests. - A restore of a backup ends the process on purpose. The host starts it again on the restored data, in about one second. - A second `createApp` on the same data folder (a hot reload) retires the first one: its crons stop and its database closes. ## How an app gets locosdk The dependency is a snapshot of committed sdk code, installed as a normal package. Every agent run and every start of the app brings it up to the newest snapshot first, so a dev app always runs the current sdk. Source: https://golocomotive.fr/docs/developers/anatomy/ # Schema as code > schema.ts declares every collection, field, index, view and rule; the types come from it and the database follows it at each boot. `schema.ts` replaces an admin collection editor and a type generator. You write the collections once in TypeScript. The record types come from that declaration, and at each boot the database is brought in line with it. ## A schema ```ts import { auth, base, defineSchema, json, number, relation, select, text, view, type RecordOf } from 'locosdk/schema' export const schema = defineSchema({ users: auth({ fields: { name: text({ max: 255 }) }, viewRule: 'id = @request.auth.id' }), clients: base({ fields: { code: text({ required: true }), owner: relation('users', { cascadeDelete: true }), tags: select(['vip', 'late'], { maxSelect: 2 }), meta: json<{ note: string }>(), }, indexes: ["CREATE UNIQUE INDEX idx_clients_code ON clients (code) WHERE code != ''"], listRule: 'owner = @request.auth.id', }), client_tags: view({ query: 'SELECT id, CAST(json_array_length(tags) AS INTEGER) AS n FROM clients', fields: { n: number() }, }), }) export type Client = RecordOf // { id, code, owner, tags: ('vip' | 'late')[], meta: { note: string } | null, created, updated } ``` ## Collections | builder | what | gets | |---|---|---| | `base({ fields, indexes, rules })` | a table | `id`, `created`, `updated` | | `auth({ fields, indexes, rules, options })` | a table whose records sign in | the above, plus `email`, `emailVisibility`, `verified`, `password`, `tokenKey` and their unique indexes | | `view({ query, fields })` | a read only collection over any SQLite SELECT | list and view only | - The rules are `listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`, plus `authRule` and `manageRule` on an auth collection. A rule left out is `null`: superusers only. See [Rules and filters](https://golocomotive.fr/docs/developers/rules.md). - `autodates: ['created']` keeps only the autodates it names, `autodates: []` none. Left out, both. - Declaring a system field by its name overrides its options. - `auth({ options })` sets sign in options: password, codes by email, two factors, alerts, token lifetimes, mail templates. See [Auth and mail](https://golocomotive.fr/docs/developers/auth.md). ## Fields Builders: `text`, `number`, `bool`, `date`, `autodate`, `email`, `url`, `editor`, `select`, `relation`, `json`, `file`, `password`. Options use PocketBase's own names (`required`, `min`, `max`, `pattern`, `onlyInt`, `maxSelect`, `cascadeDelete`, `maxSize`, `protected`, `presentable`, `help`…). The full table: [Field types](https://golocomotive.fr/docs/reference/fields.md). The inferred types: | field | TypeScript | |---|---| | text, email, url, editor, date, autodate | `string` | | number | `number` | | bool | `boolean` | | select | its values, plus `''` unless `required`; an array when `maxSelect > 1` | | relation, file | `string`; `string[]` when `maxSelect > 1` | | `json()` | `T \| null` | A relation names its target collection by name. An unknown target throws at `defineSchema`. ## Views A view is a collection over a SELECT: GROUP BY, joins, subqueries, `json_each`, `CAST`, `COALESCE` all work. Its columns get a type at sync: - the alias `id` is the record id; `count(` is an integer; - `CAST(... AS integer)`, `real`, `text`, `boolean` give that type; - a column read straight from a known collection keeps that field's type (a relation stays a relation); - anything else is json. A `*` wildcard is refused. A declared `fields` map only types the records. The sync fails when a declared field disagrees with what it infers. Use a view for a grouping the app decides, so the database does the sums: [aggregates](https://golocomotive.fr/docs/developers/client.md#aggregates). ## Sync on boot `createApp` compares `schema.ts` with the database, collection by name, and keeps the ids the database already holds. Applied by itself: - a new collection, a new field, a new index; - a changed rule or view query; - changed field options that keep the column type. Refused, with one error that lists every problem: - a dropped collection or field; - a field whose column type changes (another type, or single to multiple); - a dropped index on a table with rows; - a rename, which reads as a drop plus an add. The app does not start until the schema and the database agree. That is on purpose: no boot ever deletes data. ## Migrations A destructive change goes in a migration. Migrations run before the sync, in file order, each in a transaction, each once (they are recorded in `_migrations`). After them, the diff is additive again. ```ts import type { Migration } from 'locosdk/server' export const migrations: Migration[] = [ { file: '20261104_rename_label', up(m) { m.renameField('orders', 'label', 'title') }, }, ] // server.ts const app = createApp({ schema, migrations }) ``` The migrator offers `dropCollection`, `dropField`, `renameField`, `renameCollection`, and `db` for raw SQL on the rows. ## Storage The database is PocketBase's layout, byte for byte: one table per collection, one column per field, `_collections` holding the definitions. A `pb_data/data.db` opens as is. Ids are 15 characters `[a-z0-9]`, dates are UTC text `YYYY-MM-DD HH:MM:SS.sssZ`. The pragmas: WAL, foreign keys on, a 10 s busy timeout. Source: https://golocomotive.fr/docs/developers/schema/ # Rules and filters > API rules decide who lists, views, creates, updates and deletes each record; filters narrow a list; both use PocketBase's filter language, compiled to SQL. Rules are the security boundary of an app. Each collection carries one rule per action, written in PocketBase's filter language. The records API compiles the rule and the client's filter into one SQL expression, so a record the rule refuses never leaves the database. ## One rule per action ```ts orders: base({ fields: { owner: relation('users'), status: select(['open', 'done']), total: number() }, listRule: 'owner = @request.auth.id', viewRule: 'owner = @request.auth.id', createRule: '@request.auth.id != "" && @request.body.owner = @request.auth.id', updateRule: 'owner = @request.auth.id && @request.body.owner:isset = false', deleteRule: 'owner = @request.auth.id && status = "open"', }), ``` | value | means | |---|---| | `null` (or left out) | superusers only. Anyone else gets 403 | | `''` | anyone, signed in or not | | an expression | the record must match it, with `@request` filled from the caller | On an auth collection, `authRule` runs at sign in (the template uses `disabled = false` to switch a user off), and `manageRule` lets a record manage others: change their email or `verified` without the old password. A superuser skips every rule, as in PocketBase. ## What the caller sees on a refusal | case | answer | |---|---| | a `null` rule | 403 `Only superusers can perform this action.` | | list | the refused records are filtered out, no error | | view, update, delete | 404, so the caller cannot tell a refused record from a missing one | | create | 400 `Failed to create record.` | ## The request in a rule | path | value | |---|---| | `@request.auth.` | the signed in record. Any field, relations included: `@request.auth.team.active = true` | | `@request.body.` | the submitted data, after `field+` and `field-` are applied | | `@request.query.`, `@request.headers.` | the query string, the headers (keys snake cased: `X-Token` is `x_token`) | | `@request.method` | `GET`, `POST`… | | `@request.context` | `default`, `batch`, `mcp` (a call from an AI through the MCP server), and others | | `@collection..` | another collection, joined: `@collection.members.user ?= @request.auth.id` | A key the request does not carry is NULL. Modifiers: - `:isset` on `@request.*`: the key was sent. `@request.body.role:isset = false` forbids a field in an update. - `:changed`: sent, and different from the stored value. - `:length` on select, file, relation: the number of values. - `:each` on select, file, relation: every value matches. `tags:each ?= "vip"`. - `:lower` on any field or request value. ## Operators `= != > >= < <= ~ !~`, each with an "any of" twin `?=`, `?!=`, `?~`… A multiple value (a multi select, a multi relation, a back relation) must match for ALL its values with the plain operator, and for at least one with the `?` twin. `~` is "contains": `name ~ "ann"` is `LIKE '%ann%'`. `''` and NULL are equal for `=` and `!=`. Join with `&&` and `||` and parentheses. The full grammar, macros and functions: [Filter syntax](https://golocomotive.fr/docs/reference/filter-syntax.md). ## Filters from the client A list request carries a `filter` on top of the rule. For a caller who is not a superuser, the filter: - cannot use `@request.` or `@collection.`; - cannot read a hidden field, and sees `email` only where `emailVisibility` allows; - walks a relation only through records that the target's `listRule` lets it see. ## Bind every value A value in a filter goes through `{:name}` parameters, never through string concatenation. ```ts import { bindFilter } from 'locosdk/client' const f = bindFilter('status = {:s} && total > {:min} && created >= {:since}', { s: status, min: 100, since: new Date('2026-01-01') }) const page = await client.collection('orders').getList(1, 50, { filter: f, sort: '-created' }) ``` Strings are quoted and escaped, numbers and booleans stay raw, a `Date` becomes `"YYYY-MM-DD HH:MM:SS.sssZ"`. On the server the same function is `filter(expr, params)` from `locosdk/server`. Field names are code literals, never user input: an unknown field is an error that names it. ## Keep something from an AI The MCP server sends each tool call through the same records API with `@request.context = "mcp"`. A rule can then refuse the AI what the UI allows: ```ts deleteRule: 'owner = @request.auth.id && @request.context != "mcp"', ``` See [The MCP server](https://golocomotive.fr/docs/developers/mcp.md). ## Rules do not cover raw SQL `app.records` in a hook is the trusted service: no rules. `app.db.query(...)` is raw SQLite: no rules. A custom route that uses either one IS the access control. Guard it with `requireAuth(...)` and check what the caller is allowed to do. See [Server: hooks, routes, crons](https://golocomotive.fr/docs/developers/server.md#custom-routes). ## Limits A client filter is at most 3500 characters and 200 expressions. A sort takes at most 8 fields. Source: https://golocomotive.fr/docs/developers/rules/ # Server: hooks, routes, crons > createApp builds the app's server; hooks wrap every write and sign in, custom routes add endpoints, crons run on a schedule, all in plain TypeScript. `server.ts` creates the app, binds its behavior, and serves it. Hooks, routes and crons are plain TypeScript functions in the app's own process. There is no VM and no separate runtime. ## A full server.ts ```ts import { createApp, requireAuth, ApiError, smtpTransport } from 'locosdk/server' import config from './loco.config.ts' import { schema } from './schema.ts' import index from './src/index.html' const app = createApp({ schema, app: config.app, pages: { '/*': index }, meta: { appName: 'Orders', appURL: 'https://orders.example.com', senderName: 'Orders', senderAddress: 'no-reply@example.com' }, mail: smtpTransport({ host: 'smtp.example.com', port: 587, username: 'u', password: process.env.SMTP_PASSWORD ?? '' }), trustedProxy: { headers: ['X-Forwarded-For'] }, }) // a request hook wraps next(): only a write that persisted is logged app.onRecordUpdateRequest(async (e) => { const before = e.original await e.next() await app.records.create('audit_log', { actor: e.auth?.id ?? '', record: e.record?.id ?? '', before, after: e.record }) }, 'orders') // an after success hook: runs for every write, from a request, a hook or a script app.onRecordAfterCreateSuccess(async (e) => { const order = String(e.record.order) const row = app.db.query<{ total: number }, [string]>('SELECT coalesce(sum(amount), 0) AS total FROM payments WHERE "order" = ?').get(order) await app.records.update('orders', order, { paid: row?.total ?? 0 }) await e.next() }, 'payments') // a custom route: the route itself is the access control app.route('POST', '/api/send-invoice', async (e) => { const { to, pdf } = await e.body() if (typeof to !== 'string') throw new ApiError(400, 'Missing to.') await app.mail.send({ from: { address: app.meta.senderAddress, name: app.meta.senderName }, to: [{ address: to }], subject: 'Your invoice', html: '

Your invoice is attached.

', attachments: [{ filename: 'invoice.pdf', content: Buffer.from(String(pdf), 'base64') }], }) return e.json(200, { ok: true }) }, requireAuth('users')) // every mail gets a footer app.onMailerSend(async (e) => { e.message.html += '

Orders, 1 Example Street

' await e.next() }) app.cron('nightly', '0 23 * * *', async () => { const res = await fetch('https://api.example.com/rates') const digest = new Bun.CryptoHasher('sha256').update(await res.text()).digest('hex') app.db.query('UPDATE settings SET rates_hash = ? WHERE id = ?').run(digest, 'main') }) app.serve() ``` The options of `createApp` and the full app object: [createApp](https://golocomotive.fr/docs/reference/create-app.md). ## The hook chain A hook is a handler `(e) => …`, bound to some collections (none = every collection). Handlers run in the order they were bound. Each one MUST `await e.next()` for the default action to run. - Code before `next()` runs before the default action. Code after it runs once the action succeeded. - A `try` around `next()` catches what the action throws. - A handler that does not call `next()` stops the chain: the default action never runs. It answers with `e.json(...)` instead. Routes and their middlewares are the same chain. ## Request hooks and after success hooks | kind | fires on | use it for | |---|---|---| | `onRecordCreateRequest`, `onRecordUpdateRequest`, `onRecordDeleteRequest`, `onRecordViewRequest`, `onRecordsListRequest` | an HTTP request only | checks and changes tied to a caller: reshape `e.data`, refuse with `ApiError`, journal who did it | | `onRecordAfterCreateSuccess`, `UpdateSuccess`, `DeleteSuccess` | every committed write: a request, a hook, a script | derived values that must stay true whoever writes | Two facts bite first: 1. A request hook runs BEFORE the rule check of its write. Put side effects after `await e.next()`, or a refused write still has them. 2. A write that a hook makes through `app.records` fires the after success hooks, never the request hooks. After success hooks run after the commit, outside the write lock, in order. A throw there is logged and the write stays. The auth flows have their own request hooks (`onRecordAuthRequest`, `onRecordAuthWithPasswordRequest`, `onRecordRequestOTPRequest`…). The full list: [Hooks](https://golocomotive.fr/docs/reference/hooks.md). ## The request event | field | what | |---|---| | `e.auth` | the signed in record, or null | | `e.isSuperuser` | true for a `_superusers` record | | `e.params` | the path parameters of a route (`:id`) | | `e.body()` | the JSON or multipart body, parsed once | | `e.realIp`, `e.remoteIp` | the client address behind `trustedProxy`, and the socket's address | | `e.json(status, data)`, `e.noContent()` | answer | | `e.record`, `e.original`, `e.data`, `e.collection` | on record hooks | ## Custom routes ```ts app.route('GET', '/api/aggregate/orders', handler, requireAuth('users')) ``` - Paths take `:name` parameters and a `*` wildcard. `/api/*` never falls through to a page. - `requireAuth(...collections)` answers 401 without a signed in record of those collections. `requireSuperuser()` answers 403. - A middleware is a handler that calls `e.next()`. - A handler returns `e.json(...)`, `e.noContent()`, or a `Response`. - Rules do not apply inside a route. `app.records` is the trusted service and `app.db` is raw SQL: check the caller yourself, and read a tenant or an owner from `e.auth`, never from the query. Throw `ApiError(status, message, data?)` anywhere for a PocketBase shaped error. Any other throw answers 400 `Something went wrong while processing your request.` and is logged. ## Data from a hook or a route - `app.records.list / getOne / getFirst / create / update / delete` is the records service with no rules. It validates fields, hashes passwords, stores files and runs cascades like a request does. - `app.records.transaction(async (tx) => …)` runs several writes in one SQLite transaction. A handle used inside it joins it. - `app.db.query(sql).all(...params)` is the `bun:sqlite` database. Bind every value. A raw write skips validation and the after success hooks. ## Crons `app.cron(name, spec, fn)` takes five fields (`* , - /`) or `@hourly @daily @weekly @monthly @yearly`, in UTC, checked each minute. The same name replaces a cron. A throw is logged and the schedule goes on. A built in hourly cron cleans expired codes and idle rate limit entries. ## Mail `app.mail.send({ from, to, cc, bcc, subject, html, text, attachments })` sends through the `mail` transport and the `onMailerSend` chain. A missing text part is derived from the HTML. `smtpTransport(config)` is the real transport, `memoryTransport()` records messages in tests. With no `mail` option, sending throws. ## Body limits Every route reads at most 32 MB. A record create or update adds each file field's `maxSize` (5 MB when unset) times its `maxSelect`. `/api/batch` reads 128 MB. A body over the limit gets 413 before the handler runs. A route sets its own limit with a middleware: ```ts import { bodyLimit } from 'locosdk/server' app.route('POST', '/api/import', importHandler, requireAuth('users'), bodyLimit(1 << 30)) ``` `bodyLimit(0)` means no limit. ## Other things in reach - Outbound HTTP is `fetch`. A hash is `new Bun.CryptoHasher('sha256')`. - The audit journal binds its own request hooks: `journal(app, { collections: ['clients', 'orders'], logins: ['users'] })`, and `journalRow(app, e, { action, collection, record, label })` for an action of your own. See [UI: shell, grids, forms](https://golocomotive.fr/docs/developers/ui.md#the-audit-journal). - `app.serve({ port })` returns Bun's server. `app.close()` stops the server and the crons and closes the database. Source: https://golocomotive.fr/docs/developers/server/ # Client and data layer > The typed fetch client speaks PocketBase's wire; the MobX data layer turns it into observable lists, records and aggregates that the server pages, filters and sums. `locosdk/client` has two layers. The client is a typed fetch wrapper over the REST API. The data layer, `createDb`, gives observable objects that own their query, their page, their total, their loading state and their error. A page reads getters and never slices data itself. ## The client ```ts import { createClient } from 'locosdk/client' import { schema } from '../schema.ts' const client = createClient(location.origin, { schema }) const orders = client.collection('orders') const order = await orders.getOne(id, { expand: 'client_ref' }) order.expand?.client_ref?.name // typed through the relation ``` A collection name outside the schema fails the typecheck. A literal `fields` list types the answer: `getOne(id, { fields: 'id,label' })` is a `Pick` of those two keys. | call | request | |---|---| | `getList(page, perPage, { filter, sort, fields, expand, skipTotal })` | `GET /api/collections/:c/records` | | `getOne(id, { fields, expand })` | `GET /api/collections/:c/records/:id` | | `getFirst(filter, opts)` | a one row list; a 404 `ClientError` when empty | | `create(data)`, `update(id, data)`, `delete(id)` | `POST`, `PATCH`, `DELETE` | | `client.batch()` then `.collection(c).create/update/delete(...)` and `.send()` | `POST /api/batch`, one transaction | | `authWithPassword(identity, password)` | sign in | | `requestOTP(email)`, `authWithOTP(otpId, code, { mfaId })` | sign in with a code | | `authRefresh()` | a fresh token | | `requestPasswordReset(email)`, `confirmPasswordReset(token, password, passwordConfirm)` | password reset | | `requestEmailChange(newEmail)`, `confirmEmailChange(token, password)` | email change | | `client.fileToken()`, `client.fileUrl(record, filename, { token })` | protected files | | `client.send(path, { method, query, body })` | any custom route | - A non 2xx answer throws `ClientError { status, response, url }` with the server's message. - `client.auth` is observable: `token`, `record`, `isValid`, `clear()`. With a `storage` (`localStorage` in a browser) it persists the session. ### The second factor is in the type `authWithPassword` resolves `{ token, record }` or `{ mfaId }`. The server answers 401 both for a wrong password and for a right password that still needs a code. A caller that only catches errors tells a user with the right password that it is wrong. With the union, the compiler makes you handle the code step. ### Files ride in the data A `File` or `Blob` on a file field turns the write into multipart by itself: ```ts await orders.update(id, { scan: file }) // a single file field await orders.update(id, { 'docs+': [a, b] }) // append to a multiple field await orders.update(id, { 'docs-': ['old_x1.pdf'] }) // remove by stored name await orders.update(id, { scan: '' }) // clear a single field ``` `+` and `-` also add to and subtract from a number, a multiple select and a multiple relation. ## The data layer ```ts import { createDb } from 'locosdk/client' import { notify } from 'locosdk/ui' const db = createDb(client, { notify }) db.orders // one ObservableCollection per name, made on first read db.$client // the client ``` | class | for | complete | |---|---|---| | `LiveList` | one page of a collection, with its total | yes, paged | | `LiveChildren` | every child of one record, paged | yes, paged | | `list.refTo(target, field)` | the records the rows on screen point at, one query per 100 ids | yes | | `list.topChildrenFrom(child, field)` | the first `take` children of each row, for a cell | no: it says when it cut | | `LiveAggregate` | the grouped rows of a view | the server's, or `null` | | `LiveAggregateQuery` | a custom route that runs the SQL over the user's filter | the server's, or `null` | | `LiveScanAggregate` | a narrow read of every matching row, reduced in the page | a floor past `limit`, flagged | ### Lists ```ts const list = new LiveList(db.orders, { perPage: 50, sort: '-created', debounceMs: 300 }) list.setFilter(and(facet('status', ['open']), search(['label', 'client_ref.name'], needle))) list.rows // the page list.total // the server's count const all = await list.fetchAll({ fields: 'id,label,amount' }) // { rows, total, truncated } ``` - `setFilter`, `setSort` and `setPerPage` go back to page 1 and forget the old total. - `perPage` stops at 500, the server's page ceiling. - The filter builders bind every value: `and`, `or`, `eq`, `cmp`, `facet` (empty means no filter), `search`, `ownedBy` and `idIn` (empty means NOTHING), `eqAny`, `eqAnyRef`, `withoutBackRef`. - A multiple relation matches through its path: `eqAnyRef('tags', id)` is `tags.id ?= id`. ### Aggregates Pick the tier by who decides the grouping: 1. The app decides it: a `view` in `schema.ts` does the arithmetic, `LiveAggregate` reads it. 2. The user composes the filter: a custom route runs the SQL over it, `LiveAggregateQuery` calls it. 3. Neither exists yet: `LiveScanAggregate` reads one column of every matching row. It costs one row per record and says when it stopped at its limit. Every aggregate answers `null` until the server proved it. Never write `?? 0`: a zero nobody computed is a lie on screen. The two server tiers take `watch`, the collections whose writes move the number. ```ts const byStatus = new LiveAggregate(db.orders_by_status, { watch: [db.orders] }) const late = byStatus.by('status', 'late')?.count ?? null ``` ### Writes - `db.orders.update(id, patch)` changes the cached record at once, then sends. Writes to one collection inside 50 ms go as one `POST /api/batch`. A refusal shows the server's field errors through `notify` and puts the server's value back. - A write with a file goes alone and at once, as multipart. - `record.updateAndSave(patch, 500)` is the form autosave: debounced, it sends only the changed fields and rolls back on refusal. - `record.assign(patch)` changes the cache only. `save()`, `rollback()`, `delete()` act on it. - `create` and `delete` refetch the lists; an edit refetches only the aggregates. ### Query `Query(load)` is the one async primitive: observable `result`, `error`, `isRunning`, `run()` to refresh. A stale answer never overwrites a newer one. Make it once and keep it: a `new Query` inside a render fetches on every render. ## Tests `createStub({ schema })` is the wire in memory. Hand `stub.fetch` to `createClient`, seed `stub.lists`, answer queries with `stub.listResponders`, then assert what was ASKED: `stub.listCalls` (page, filter, sort, fields), `stub.sendCalls`, `stub.requests`. It takes multipart writes and password resets like the server. ```ts const stub = createStub({ schema }) const client = createClient('https://orders.example.com', { schema, fetch: stub.fetch }) stub.lists.orders = [{ id: 'o1', label: 'A', status: 'open', amount: 10 }] ``` Source: https://golocomotive.fr/docs/developers/client/ # UI: shell, grids, forms > locosdk/ui gives the app its shell, sign in, server paged grids, record cards, the schema form, the guarded delete, the audit journal and the what's new modal. `locosdk/ui` is the top tier: React 19, Mantine 8, MobX. An app's screens declare their own columns and fields. The login, the shell, the grids, the pager, the record card, the form, the delete guard and the export come from here. ## Setup and the root ```tsx import 'locosdk/ui/setup' // always the first line of the entry import { createRoot } from 'react-dom/client' import { AppRoot } from 'locosdk/ui' import config from '../loco.config.ts' import { client } from './data.ts' import { App } from './App.tsx' createRoot(document.body.appendChild(document.createElement('div'))).render( , ) ``` - `setup` installs three globals: `X` (Mantine components), `H` (Mantine hooks), `I` (Material Symbols icons, one component per name: ``). The page head needs the Material Symbols font link; the template carries it. - `AppRoot` holds the Mantine provider, modals and notifications. Its theme derives from the identity's color. The tab takes the app's name and logo. With `client`, it shows the what's new modal after sign in ([Changelog and releases](https://golocomotive.fr/docs/developers/releases.md)). - `useAppIdentity()` reads the identity in any screen. Show `app.name`, never a copy of it. - `makeHouseTheme({ brand: '#0b7285' })` gives the default theme from one color, with `overrides` for the rest. ## Sign in and routes ```tsx }, { path: 'clients/:id', element: }, { path: 'orders', label: 'Orders', icon: 'receipt_long', group: { caption: 'Sales' }, element: }, ]} /> ``` `AppRouter` builds `/login`, the guard, the layout (header with the profile menu, navbar, the page), a not found page and an error page. A page with a `label` shows in the navbar; `group` files it under a caption; `show` hides its entry. Home goes to the first navbar page. `/_/` answers the links in the mails (confirm an email change, reset a password). `Session(client, { collection = 'users', roleField = 'role' })` holds the signed in user: `user`, `display`, `email`, `role`, `isSuperuser`, `isSignedIn`, `signIn()`, `signOut()`, `refresh()`. A right password that still needs a code opens the code step and mails the code. The profile menu offers the account (name, email change), the password and the language. ## A collection, described once ```tsx export const orders = defineCollectionUI(db.orders, { title: 'Orders', label: (o) => o.label, href: (o) => `/orders/${o.id}`, sort: '-created', form: { fields: ['client_ref', 'label', 'status', 'amount'] }, columns: [ { id: 'label', label: 'Order', value: (o) => o.label }, { id: 'client_ref.name', label: 'Client', sort: false, render: (o) => }, { id: 'status', label: 'Status', value: (o) => o.status, facet: ['open', 'done'], search: false }, { id: 'amount', label: 'Amount', value: (o) => o.amount, align: 'right', search: false }, ], }) ``` In a grid, a column's `id` is its SERVER field (a path like `client_ref.name` works). The sort, the search and the facets compile from it into the server query. A column with no server field says `sort: false, search: false`. `href` is the record's page; without it a row opens the form. Two columns with one id throw. ## Grids | component | shows | |---|---| | `LiveGrid({ ui, filter?, deletable?, selection?, exportMenu?, importable? })` | a collection, server paged: search, sort, facets with counts, pager up to 500 rows, the query kept in the URL | | `RelatedGrid({ ui, via, parent })` | every child of one record (1 to many), paged | | `JunctionGrid({ through, via, to, ui, parent })` | many to many through a junction collection | | `RefCell({ row, field, ui })` | a relation cell: the target's label, linked. One query for the whole page | | `RecordCard({ ui, id, onDeleted? })` | one record: its columns as label and value, Edit, Delete | - `filter` scopes a grid. The user's query only narrows it. - `exportMenu={{ filename: 'orders' }}` adds Export: the columns, the selected rows or the WHOLE filtered list (up to 20 000 rows by default), csv, xlsx or pdf. A cut export says so. `formats: ['csv']` keeps it to csv. - `selection={useSelection()}` adds checkboxes, shift click for a range, and a `Selection` the page reads for bulk actions. - `DataTable` is the card under every grid, for rows the page already holds. ## The schema form `form: { fields: ['name', 'city'] }` builds one input per field from the schema the client holds: text, number, switch, date (local time, sent in UTC), select, relation (a select the server searches as the user types, by the target's `presentable` fields), json, file. `required` is checked before sending. Save waits for the server and shows its field errors under their inputs. An edit sends only the changed fields. A file field sends `field+` and `field-`. `SchemaForm` is the modal alone, `FormState` its state, for a page that builds its own form. ## The guarded delete `deletable` on a grid, or `onDeleted` on a record card, deletes through `guardedDelete`. It counts every record that points at this one, from the schema. A proven reference refuses with a red notice that says where. A count it cannot get refuses too. Otherwise it asks to confirm. ## The audit journal One row per persisted write of the collections you name, and one per sign in if you ask. ```ts // schema.ts audit_log: auditLog({ readRule: '@request.auth.role = "admin"' }), // server.ts journal(app, { collections: ['clients', 'orders'], logins: ['users'] }) ``` - Every write rule of `audit_log` is null: no request can add, edit or delete a row. - A row holds the actor, the action, the record and its label, the changed fields (from and to), the IP, the user agent, and the context (`default`, `batch`, or `mcp` for a write an AI made). - A refused write leaves no row. A write made by a hook or a cron leaves none either. - `` is the whole journal as a grid. `` is one record's history. ## Page state and preferences - `useSearchState(spec)` keeps filters in the URL as typed search params. `useRouteTab` keeps a tab in the route. - `readPref` and `writePref` keep a viewer's own choices in localStorage. - `uiI18n` holds every string the UI shows, in English and French. `uiI18n.set('fr')` moves the app, the sdk and the UI together. ## Tests The UI suites run in happy-dom. `domSuite()` sets it up, `typeInto(el, value)` types a value React sees. A worked example (sign in, a list, a client and its orders, a new order through the schema form, a delete refused by a reference) ships with the sdk and fails when the examples on this page drift from it. Source: https://golocomotive.fr/docs/developers/ui/ # Auth and mail > Passwords, codes by email, two factors, password reset, email change, sign in alerts, the rate limit and the mails, all declared in schema.ts. An auth collection is a collection whose records sign in. Every option of it lives in `schema.ts`, beside its fields. The server checks the options at boot, as PocketBase does, and refuses a combination that cannot work. ## Options in schema.ts ```ts import { auth, bool, mailTemplatesFr, text } from 'locosdk/schema' const fr = mailTemplatesFr({ otpSeconds: 600 }) users: auth({ fields: { name: text(), role: text(), disabled: bool() }, indexes: ['CREATE UNIQUE INDEX idx_users_name ON users (name)'], authRule: 'disabled = false', options: { passwordAuth: { identityFields: ['email', 'name'] }, otp: { enabled: true, duration: 600, length: 6, emailTemplate: fr.otp }, mfa: { enabled: true, rule: "role = 'admin'" }, authAlert: { enabled: true }, authToken: { duration: 7 * 86400 }, resetPasswordTemplate: fr.resetPassword, }, }), ``` - Every key is optional. A key left out is the default, so removing a line turns the option back to its default. - A schema never holds a token secret: the type has no slot for one. Secrets are minted once in the database and survive every boot. - The boot refuses: two factors with fewer than two methods on, an identity field without a single column unique index, a mail template with an empty subject or body, a duration outside its range. ## Defaults | what | default | |---|---| | auth token | 12 hours, refreshable | | file token | 180 seconds | | verification link | 1 day | | password reset link, email change link | 30 minutes | | code by email | 8 digits, 180 seconds | | two factor session | 600 seconds | | password | 8 characters minimum, 71 maximum, bcrypt cost 10 | ## Signing in - **Password**: `POST /api/collections/users/auth-with-password` with `identity` and `password`. The identity fields are `email` by default. - **Code by email**: `request-otp` mails a code and answers an `otpId`, also for an unknown address, so nobody can probe accounts. `auth-with-otp` signs in with it. Wrong codes are limited to 5 per 180 seconds per account. - **Two factors**: with `mfa.enabled`, a first factor answers 401 `{ mfaId }` when `mfa.rule` matches the record. The second factor sends that `mfaId` with a DIFFERENT method. The client's `authWithPassword` returns `{ mfaId }` in its type, and `Session` opens the code step by itself ([Client and data layer](https://golocomotive.fr/docs/developers/client.md#the-second-factor-is-in-the-type)). - **The auth rule** runs after the factors: `authRule: 'disabled = false'` refuses a switched off account with 403. - **Superusers** sign in on `_superusers`. `golo superuser upsert ` creates one or sets its password, from stdin. ## Changing a password or an email - A user changing their own password sends `oldPassword`. Without it, or with a wrong one, the update fails: a stolen session cannot set a new password. - `request-password-reset` mails a link (at most one per 2 minutes) and answers the same whether the address exists or not. `confirm-password-reset` sets the password and ends every session of the account. - `request-email-change` mails a link to the NEW address. `confirm-email-change` takes the link's token and the account password, sets the email and `verified`, and ends every session. - A password or email change rotates the record's `tokenKey`. Every token of the record dies at once, codes and MCP access tokens included. ## Sign in alerts With `authAlert.enabled` (the default), a password or code sign in from a new device mails the account an alert with the time, the IP and the browser. The first sign in only records its device. An account keeps its 5 newest devices. A failed alert is logged and never blocks the sign in. ## The rate limit Password and code sign ins are limited to 5 per 3 seconds per client IP, then 429 `Too Many Requests.` Behind a proxy, set `trustedProxy` so the limit sees the real client and not the proxy ([Files, backups and proxies](https://golocomotive.fr/docs/developers/files-backups.md#behind-a-proxy)). `createApp({ rateLimits })` takes PocketBase's rate limit rules for other labels. ## Mail ```ts import { createApp, smtpTransport } from 'locosdk/server' const app = createApp({ schema, meta: { appName: 'Orders', appURL: 'https://orders.example.com', senderName: 'Orders', senderAddress: 'no-reply@example.com' }, mail: smtpTransport({ host: 'smtp.example.com', port: 587, username: 'orders', password: process.env.SMTP_PASSWORD ?? '' }), }) ``` - The SMTP client is built in, with no dependency: STARTTLS when the server offers it, implicit TLS with `tls: true`, AUTH PLAIN or LOGIN. It refuses to send a password over plain text, except to the local machine. - `smtpProbe(config)` connects, authenticates and quits: `{ ok: true }` or `{ ok: false, error }`, never the password. - Five mails per auth collection: `otp`, `authAlert`, `resetPassword`, `verification`, `confirmEmailChange`. English is PocketBase's text. French comes from `mailTemplatesFr({ otpSeconds })`. - Placeholders: `{APP_NAME}`, `{APP_URL}`, `{TOKEN}`, `{OTP}`, `{OTP_ID}`, `{ALERT_INFO}`, and `{RECORD:}` for any field that is not hidden. - Every mail goes through `onMailerSend`, where a hook can add a footer or change the recipients. ## Not here yet > **Planned.** OAuth2 sign in with an outside provider, email verification as a sign in requirement, impersonation and listing the auth methods are not built. A syntax check of `mfa.rule` at boot is not built either: a bad rule fails at sign in. Source: https://golocomotive.fr/docs/developers/auth/ # Files, backups and proxies > File fields on disk or S3, protected files behind short tokens, backups made while the app writes, restores, and the real client address behind a proxy. ## File fields ```ts orders: base({ fields: { scan: file({ maxSize: 10 << 20, mimeTypes: ['application/pdf', 'image/png'] }), docs: file({ maxSelect: 5, protected: true }), }, }), ``` - A file field holds stored names: `string`, or `string[]` when `maxSelect > 1`. The default size limit is 5 MB. - Names are PocketBase's: `_<10 random chars>.`. - A write sends files in the data and the client makes it multipart ([Client and data layer](https://golocomotive.fr/docs/developers/client.md#files-ride-in-the-data)). `docs+` appends, `docs-` removes by stored name, `scan: ''` clears. - Files are written before the commit and removed if it rolls back. Replaced and removed files are deleted after the commit. - The schema form shows the stored files as links, with a remove button and a picker. ## Protected files A `protected: true` field is served only with a file token, and only to a caller the record's `viewRule` lets through. ```ts const token = await client.fileToken() // POST /api/files/token, valid 180 s const href = client.fileUrl(order, order.docs[0] ?? '', { token }) ``` `GET /api/files/:collection/:record/:filename` serves the bytes under a sandboxing content security policy. `?download=1` asks for an attachment. A plain file needs no token. ## Files on S3 ```ts import { createApp, s3Storage } from 'locosdk/server' const app = createApp({ schema, storage: s3Storage({ bucket: 'orders-files', region: 'fra1', endpoint: 'https://fra1.digitaloceanspaces.com', accessKey: process.env.S3_KEY ?? '', secret: process.env.S3_SECRET ?? '', forcePathStyle: false, prefix: 'orders', }), }) ``` - A file lives at `//`, PocketBase's key, so a bucket PocketBase filled serves as is. - `prefix` puts every key under `/`: one bucket for several apps. - `forcePathStyle: false` puts the bucket in the host (DigitalOcean Spaces, AWS). `true` puts it in the path (MinIO). - The file route streams the object through the app, protected files included. It uses Bun's own S3 client, no dependency. ## Backups A backup is PocketBase's zip of the data folder: `app.db`, then the uploaded files on disk storage, the review media and the rest. It never holds the backups themselves or the S3 secret. ```ts const app = createApp({ schema, backups: { cron: '0 3 * * *', maxKeep: 7 }, // on disk, in data/backups }) ``` - The database is copied with `VACUUM INTO` while the write lock is held, and disk files are hard linked under the same lock. The zip is one consistent moment, even while the app writes. - On S3 file storage, the files stay in their bucket and only the database is zipped. - `backups.storage` is `'disk'` or an S3 config. Give backups their own bucket, or a `prefix`. - The cron writes `@auto_pb_backup__.zip` and keeps the newest `maxKeep` (0 keeps all). - One backup or restore runs at a time. A second one gets 400 `Try again later`. - Hooks: `onBackupCreate` can rename the zip and leave entries out, `onBackupRestore` can keep entries in place. ### Restore A restore stages first, with the app still serving. It extracts the zip apart, checks every entry and opens the staged database. Only then the app stops, saves what it replaces as `@pre_restore__.zip`, swaps the folder entry by entry, and restarts. A failure before the swap leaves the data as it was. The process ends on purpose: the hooks and crons of `server.ts` belong to the old database. The host starts the app again on the restored data in about one second. A PocketBase zip restores too: its `data.db` becomes `app.db`. ### Routes and cli | | what | |---|---| | `GET /api/backups` | the list: key, size, modified. Superuser only | | `POST /api/backups` | make one now, optional `name` | | `POST /api/backups/upload` | upload a zip, streamed, no size limit | | `GET /api/backups/:key?token=` | download, with a superuser's file token | | `DELETE /api/backups/:key` | delete one | | `POST /api/backups/:key/restore` | restore, then restart | | `golo backup create [name]` | the same from the app folder, the app running or not | | `golo backup list [--json]` | the store the app last started with | | `golo backup restore ` | refuses while the app runs: stop it first | The cli runs no app code, so no backup hook fires from it. ## Behind a proxy Without help, an app behind a proxy sees every request come from the proxy. The auth rate limit then sees one client for everybody. ```ts const app = createApp({ schema, trustedProxy: { headers: ['X-Forwarded-For'] } }) ``` For each listed header the request carries, the rightmost valid IP is `e.realIp` (the leftmost with `useLeftmostIP: true`). The rate limit keys on `realIp`. `e.remoteIp` stays the socket's address. The setting is off by default, like PocketBase. The app template turns it on for the header our front door sets. ## Not here yet > **Planned.** Image thumbs (`?thumb=`), the backup failure mail to every superuser (a failure is logged only), and an IP filter on the backup download. Source: https://golocomotive.fr/docs/developers/files-backups/ # The MCP server > One option turns an app into an MCP server, so a client's own Claude or ChatGPT works in the app as that user, under the same rules, hooks and journal as the UI. `createApp({ mcp: {} })` serves the app as an MCP server at `/mcp`. A client connects the AI they already use and works in the app AS THEMSELVES. Every tool call goes through the same route, rules, hooks and journal as a click in the UI. The AI is theirs and they pay for it. The option is off by default. How a client connects their AI, step by step: [Connect your agent](https://golocomotive.fr/docs/connect.md). ## Turn it on ```ts const app = createApp({ schema, meta: { appURL: 'https://orders.example.com' }, // the public URL: the MCP resource and the OAuth issuer derive from it mcp: { hide: ['audit_log', 'users.delete'], instructions: 'Orders of a shop. Amounts are in euros.', }, }) // an action of the app: the route as usual, plus mcpTool(...) among its middlewares app.route('POST', '/api/orders/:order/send-invoice', sendInvoice, requireAuth(), mcpTool({ name: 'send_invoice', description: 'Mail the invoice of one order to its client.', })) ``` | option | default | what | |---|---|---| | `url` | `/mcp` | the public URL of the endpoint: the audience of every token | | `auth` | every auth collection except `_superusers` | the collections whose records can connect | | `hide` | `[]` | a collection (`'audit_log'`) or one action of it (`'users.delete'`); actions are `list view create update delete` | | `superusers` | `false` | a superuser's token works on `/mcp` only when true | | `oauth` | `true` | the built in authorization server; `false` keeps bearer tokens only | | `instructions` | none | one paragraph for the model: what the app is, its units. Sent after the primer | | `origins` | `[]` | browser origins allowed beside the app's own | Set `meta.appURL` to the app's public address. The OAuth metadata and the token audience are built from it. ## The tools Every app offers the same seven tools, whatever the size of its schema. The model reads the data's shape from `schema`, then works on any collection by name: | tool | arguments | request | |---|---|---| | `schema` | none | none: the collections this caller may use, as text | | `list` | `collection filter sort page perPage fields expand` | `GET /api/collections/:collection/records` | | `get` | `collection id fields expand` | `GET /api/collections/:collection/records/:id` | | `create` | `collection data` | `POST /api/collections/:collection/records` | | `update` | `collection id data` | `PATCH /api/collections/:collection/records/:id` | | `delete` | `collection id` | `DELETE /api/collections/:collection/records/:id` | | `batch` | `requests: [{ action, collection, id?, data? }]`, at most 50 | `POST /api/batch`: one transaction, all or none | `schema` answers one line per collection: its kind, the actions the caller may run, and its fields with their types: ``` orders: list get create update | order_no text!, client →clients!, status select(open|done), total int 0.., due date, created auto, updated auto users (auth): list get | email email, name text, role select(staff|boss) ``` `!` is required, `[]` many, `→clients` a relation (its value is a record id), `"…"` the field's `help`, `auto` a date the server sets. - A caller sees a collection when it is not a system one, not hidden, and has at least one action whose rule is not `null`. A view offers `list` and `get` only. - A collection the caller cannot see is refused by every tool with the same message as a collection that does not exist. A `batch` checks each of its requests first and runs none when one fails. - Hidden fields, passwords, `tokenKey` and the rules themselves never appear. A file field shows as `file`: files go through the app's UI. - `schema`, `list` and `get` carry `readOnlyHint`; `delete` and `batch` carry `destructiveHint`. - The server's instructions start with a short primer: call `schema` first, the filter syntax ([Rules and filters](https://golocomotive.fr/docs/developers/rules.md)), `sort`, `fields`, `expand`, how `data` takes relations and dates, how a refusal reads. The app's `instructions` follow it. ### The app's own actions `mcpTool({ name, description, input?, readOnly?, destructive? })` among a route's middlewares offers that route as a tool. Only the routes marked this way become tools. - The name is 1 to 64 of `A-Z a-z 0-9 _ -`, not one of the seven, and unique among the app's routes. A bad or repeated name, or a route with a `*` wildcard, throws at `app.route`. - Each `:name` in the path is a required argument. The other arguments become the query string on GET and DELETE, and the JSON body otherwise. - The route is its own access control, as for the UI: `requireAuth()`, or the checks in its handler. ## One code path A tool call is an internal request through the app's own router, `app.dispatch`. The route, its middlewares, the request hooks, the rule, the after success hooks and the journal all run, exactly as for the UI. The MCP code never touches the records service directly. - The request carries the caller's record, the IP and user agent of the MCP request, and `@request.context = "mcp"`. - A rule can refuse the AI something the UI allows: ```ts deleteRule: 'owner = @request.auth.id && @request.context != "mcp"', ``` - The journal writes `context = mcp` on every row an AI caused. - A refused call (a rule, a 404, a validation error) is a tool result with `isError: true` and the route's `{ status, message, data }`. The model reads why. ## Auth Every request to `/mcp` needs `Authorization: Bearer `. Without one it gets 401 with a `WWW-Authenticate` header that starts the OAuth flow. - **A bearer token**: the `token` of `auth-with-password` works as is, for scripts and Claude Code. It lives as long as the session token (12 hours by default). ```sh claude mcp add --transport http orders https://orders.example.com/mcp --header "Authorization: Bearer " ``` - **OAuth 2.1**, for connectors such as claude.ai, Claude desktop and ChatGPT (built to the MCP authorization spec and checked with the official MCP clients; no such connector has been tried against a live app yet): dynamic client registration, PKCE S256, and a consent page served by the app on its own origin. A signed in user sees "Allow to act as ?". A signed out user signs in there with their password. - An MCP access token lives 1 hour and works on `/mcp` only. A refresh token lives 30 days and rotates at each use. A password or email change kills both. The routes, the token kinds and the error codes: [MCP protocol and OAuth](https://golocomotive.fr/docs/reference/mcp-protocol.md). ## Safety - Writes are rate limited per signed in record: 60 per 60 seconds by default, the `rateLimits` rule labelled `mcp:write`. `create`, `update`, `delete` and every app tool not marked `readOnly` count one per call; a `batch` counts one per request inside it. Past the limit, the tool answers 429 as an error result. - A superuser never reaches `/mcp` unless `superusers: true`. - A request with a foreign `Origin` header gets 403, against DNS rebinding. - Any request a tool makes stays on that tool's own route. A batch made over MCP keeps the `mcp` context, so the journal and the rules see it. - A client revokes a token at `POST /oauth/revoke` (RFC 7009, listed in the metadata), and the access tokens of that grant stop at once. A registration unused for 30 days is deleted. ## Not here yet > **Planned.** Client ID metadata documents, a page where a user sees and removes the AIs they connected, codes and two factors on the consent page (an account under two factors signs in to the app first), MCP resources and prompts, and file uploads through tools. Source: https://golocomotive.fr/docs/developers/mcp/ # Review cards and shots > How an agent shows the client a change as a card with a before and an after, reads the client's verdict, and works through what the client raises, with golo review and golo shot. Every visible change reaches the client as a CARD in the portal, beside the live app. Everything the client raises (a bug, a request) is an ITEM of the same store. An agent acts on both with `golo review` only, never in a chat: nobody reads a chat. The client's side of it: [Review cards](https://golocomotive.fr/docs/using/review.md). ## A card for one change ```sh bunx golo shot orders --at HEAD --out .loco/shots/before.png # before any edit: the app as committed # edit the code, let the app reload bunx golo shot orders --out .loco/shots/after.png # after: the app as it runs now git add src/collections/orders && git commit -m "orders: overdue badge" -- src/collections/orders bunx golo review add --answers --commit $(git rev-parse --short HEAD) \ --title "Overdue orders show a red badge" \ --what "In the Orders list: - **the red badge** on orders past their due date" \ --before .loco/shots/before.png --after .loco/shots/after.png ``` - Shoot the before FIRST. `--at HEAD` serves the app at that commit, apart from the running one. - Commit BEFORE the card. An uncommitted change is lost to the next agent run. - One subject is one card. Other angles of it (phone and desktop, empty and full) are VIEWS of that card: `--view "