service.json configuration reference
This guide explains how to write service.json for Service Tree. By the end, you can wrap underlying datasets, SQL, or Backend Functions into business commands such as:
lovrabet crm customer list
lovrabet crm customer detail 10001
lovrabet crm customer contact list --customer-id 10001The recommended format is tree-shaped: resources represent business objects, and actions define what you can do with each object. The legacy commands array still works, but keep it only for backward compatibility with older configurations.
Start with the minimal structure
A service configuration typically has four parts:
service: the service entry point, for examplecrm.app/apps: which app the service uses by default.resources: the business objects in the service, such as customers, contacts, and follow-up records.actions: what can be done with each business object, such as list, detail, and create.
Minimal example:
{
"service": "crm",
"name": "CRM",
"description": "客户、联系人和跟进记录的业务服务",
"app": "app-xxxxxxxx",
"resources": {
"customer": {
"name": "客户",
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"actions": {
"list": {
"description": "查看客户列表",
"defaults": {
"currentPage": 1,
"pageSize": 20,
"orderBy": [{ "updated_at": "desc" }]
},
"flags": {
"status": "where.status.$eq",
"keyword": {
"to": "where.customer_name",
"op": "$contain",
"description": "客户名称关键字"
}
}
},
"detail": {
"description": "查看客户详情",
"action": "getOne",
"args": ["id"],
"map": {
"id": {
"target": "id",
"transform": "number"
}
}
}
}
}
}
}This generates two business entry points:
lovrabet crm customer list --status active --keyword 科技
lovrabet crm customer detail 10001They map to:
lovrabet data filter --code aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --params '{"where":{"status":{"$eq":"active"},"customer_name":{"$contain":"科技"}},"currentPage":1,"pageSize":20,"orderBy":[{"updated_at":"desc"}]}'
lovrabet data getOne --code aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --params '{"id":10001}'Top-level fields
| Field | Required | Description |
|---|---|---|
protocol | No | Protocol name. Defaults to lovrabet.service-tree/v1. |
version | No | Configuration file version. Defaults to 1.0.0 when omitted. |
service | Yes | Service entry point. A string such as "crm" is recommended; { "code": "crm", "name": "CRM" } is also supported. |
name / description | No | Display name and description. Shown in help, schema, and doctor output. |
app | No | Shortcut for binding a single app. Accepts an appcode or an app name. |
apps | No | Multi-app binding table. Recommended for new configurations. |
appBindings | No | Legacy name with the same semantics as apps, kept for compatibility. |
defaults | No | Service-level default underlying parameters, inherited by resources and actions. |
resources | Recommended | The business object tree. Prefer this in new configurations. |
commands | Legacy | The legacy flat command array. Still works, but no longer recommended. |
service is not an app code. Don't write it as app-xxxx. A service may span multiple apps; service should express the business domain, such as crm, order, or store-ops.
app and apps: binding apps
A single-app service can simply use app:
{
"service": "crm",
"app": "app-xxxxxxxx"
}If a service spans multiple apps, use apps:
{
"service": "customer-success",
"apps": {
"crm": {
"appcode": "app-crmxxxx",
"env": "daily"
},
"order": {
"appcode": "app-orderxxxx",
"env": "daily"
}
}
}Resources or actions reference an app alias through appRef:
{
"resources": {
"customer": {
"appRef": "crm",
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"actions": { "list": {} }
},
"order": {
"appRef": "order",
"datasetCode": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"actions": { "list": {} }
}
}
}app, apps, and appBindings are only defaults. Apps or environments explicitly specified by the user at command time take precedence.
resources: defining the business object tree
Each key in resources becomes one segment of the command path.
{
"resources": {
"customer": {
"name": "客户",
"resources": {
"contact": {
"name": "联系人",
"actions": {
"list": {}
}
}
}
}
}
}This generates:
lovrabet crm customer contact listCommon resource fields:
| Field | Description |
|---|---|
name / description | Display information for the business object. |
appRef / app | References an app alias from apps / appBindings. Common in multi-app services. |
datasetCode | Dataset code. The preferred option. |
datatable / table | Physical table name. When datasetCode is absent, the dataset can be resolved by table name. |
defaults / params | Resource-level default underlying parameters, such as pagination, sorting, and fixed conditions. |
actions | Actions on this business object, such as list, mine, detail, and create. |
resources | Child business objects, for deeper command paths. |
appRef, datasetCode, datatable, and defaults on a resource are inherited by its child resources and actions. Actions can override these values.
actions: defining business actions
Each key in actions is the last segment of the command path.
{
"actions": {
"list": {
"description": "查看客户列表"
},
"detail": {
"description": "查看客户详情",
"action": "getOne",
"args": ["id"],
"map": {
"id": {
"target": "id",
"transform": "number"
}
}
}
}
}Common action fields:
| Field | Description |
|---|---|
description | Command description. |
action | Action shorthand. Defaults to filter. Can be getOne, create, update, delete, or sql.exec, bff.exec. |
target | Full target form. Can be an object, or data.filter, sql.exec, bff.exec. |
kind / command | Expanded form of target, for example { "kind": "data", "command": "filter" }. |
datasetCode / datatable / table | Overrides the dataset locator inherited from the resource. |
sqlCode | SQL code. Used with action: "sql.exec". |
bffCode / bffId / scriptName | Backend Function locator. Used with action: "bff.exec". |
args | Positional arguments. A string shorthand automatically becomes a required argument. |
flags | Optional parameters. Object form recommended. |
defaults / params | Action-level default underlying parameters. |
map / mapTo | Parameter mapping rules. Use map in new configurations; mapTo in legacy configurations still works. |
risk | Risk level. Optional for read-only actions; use write for writes; use high-risk-write for deletions or irreversible operations. |
Choosing an action form
1. Listing dataset records
When action is omitted, it defaults to data.filter:
{
"resources": {
"customer": {
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"actions": {
"list": {
"description": "查看客户列表"
}
}
}
}
}Generates:
lovrabet crm customer listMaps to:
lovrabet data filter --code aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --params '{}'2. Getting a single record
{
"actions": {
"detail": {
"description": "查看客户详情",
"action": "getOne",
"args": ["id"],
"map": {
"id": {
"target": "id",
"transform": "number"
}
}
}
}
}Generates:
lovrabet crm customer detail 10001Maps to:
lovrabet data getOne --code aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa --params '{"id":10001}'3. Running SQL
{
"actions": {
"stats": {
"description": "查看客户统计",
"action": "sql.exec",
"sqlCode": "customer_stats",
"flags": {
"startDate": {
"to": "startDate",
"description": "开始日期"
},
"endDate": {
"to": "endDate",
"description": "结束日期"
}
}
}
}
}Generates:
lovrabet crm customer stats --start-date 2026-01-01 --end-date 2026-01-31Maps to:
lovrabet sql exec --sqlcode customer_stats --params '{"startDate":"2026-01-01","endDate":"2026-01-31"}'4. Running a Backend Function
For complex writes, prefer wrapping the logic in a Backend Function so the server handles validation, idempotency, permissions, transactions, and failure recovery.
{
"actions": {
"create": {
"description": "创建客户",
"action": "bff.exec",
"scriptName": "createCustomer",
"risk": "write",
"flags": {
"customerName": {
"to": "customerName",
"required": true,
"description": "客户名称"
},
"industry": {
"to": "industry",
"description": "所属行业"
}
}
}
}
}Generates:
lovrabet crm customer create --customer-name 云兔科技 --industry softwareMaps to:
lovrabet bff exec --name createCustomer --params '{"customerName":"云兔科技","industry":"software"}'args: values the caller must supply
args is for values that must be provided at call time, such as a record ID or a customer number.
Shorthand:
{
"args": ["id"]
}Full form:
{
"args": [
{
"name": "id",
"description": "客户编号",
"required": true
}
]
}args only declares what values to collect. To place a value into the underlying request, you also need map:
{
"args": ["id"],
"map": {
"id": {
"target": "id",
"transform": "number"
}
}
}flags: optional query conditions
flags works well for conditions such as status, owner, keywords, date ranges, and pagination.
Object form recommended:
{
"flags": {
"status": "where.status.$eq",
"ownerId": {
"to": "where.owner_id",
"op": "$eq",
"type": "number",
"transform": "number",
"description": "负责人 ID"
},
"startDate": {
"to": "where.created_at",
"op": "$gte",
"description": "开始日期"
},
"pageSize": {
"to": "pageSize",
"type": "number",
"transform": "number",
"description": "每页条数"
}
}
}On the command line, camelCase is automatically converted to kebab-case:
lovrabet crm customer list --owner-id 12 --start-date 2026-01-01 --page-size 50The above maps to:
{
"where": {
"owner_id": { "$eq": 12 },
"created_at": { "$gte": "2026-01-01" }
},
"pageSize": 50
}Flag object fields:
| Field | Description |
|---|---|
to / target | Mapping target path. |
op / operator | Operator, such as $eq, $contain, $gte. |
type | Parameter type: string, number, boolean, json. Defaults to string when omitted. |
transform | Type conversion: string, number, boolean, json. |
description | Parameter description. |
required | Whether the parameter is required. |
default | Default value. |
enum | List of allowed values. |
cliName | Parameter name shown on the command line. When omitted, it is derived from name in kebab-case. |
omitEmpty | Whether to skip mapping when the value is empty. |
map and mapTo: translating business parameters into underlying parameters
map / mapTo answers one question: where in the underlying request does each business parameter supplied by the caller go?
Use map in new configurations; mapTo from legacy configurations is still supported.
Common sources
| Source | Description | Example |
|---|---|---|
flags.xxx | From an optional parameter. | flags.status |
args.xxx | From a positional argument. | args.id |
context.xxx / ctx.xxx | From the current logged-in user or the runtime context. | context.userId |
const.xxx | Fixed value. | const.deleted |
const | Fixed-value source. | const |
In map, a key without a prefix is matched against args or flags automatically. For example, with args: ["id"], "id" is treated as args.id.
Shorthand: write the target path directly
{
"map": {
"status": "where.status.$eq"
}
}This is equivalent to placing the status parameter at:
{
"where": {
"status": { "$eq": "<status>" }
}
}Full form: target, operator, and transform written separately
{
"map": {
"ownerId": {
"target": "where.owner_id",
"operator": "$eq",
"transform": "number"
}
}
}Business command:
lovrabet crm customer list --owner-id 12Underlying parameters:
{
"where": {
"owner_id": { "$eq": 12 }
}
}Fixed conditions
Fixed conditions express rules such as "show only non-deleted records by default" or "show only a certain type of customer by default".
{
"map": {
"const.deleted": {
"target": "where.deleted",
"operator": "$eq",
"value": 0,
"transform": "number"
}
}
}Underlying parameters:
{
"where": {
"deleted": { "$eq": 0 }
}
}Current user
For "my customers", don't hardcode a specific user ID — use the runtime context:
{
"actions": {
"mine": {
"description": "查看我负责的客户",
"map": {
"context.userId": {
"target": "where.owner_id",
"operator": "$eq",
"transform": "number"
}
}
}
}
}Generates:
lovrabet crm customer mineUnderlying parameters:
{
"where": {
"owner_id": { "$eq": "<当前登录用户 ID>" }
}
}defaults and params: default underlying parameters
defaults and params both specify default underlying parameters. Use defaults consistently in new configurations; params mainly exists for backward compatibility with older configurations.
They can be written at three levels:
- Top-level
defaults: inherited by the entire service. - Resource
defaults: inherited by this business object and its children. - Action
defaults: affects only the current action.
Values merge from the outside in — the closer to the action, the higher the precedence:
service.defaults -> resource.defaults -> action.defaultsExample:
{
"defaults": {
"where": {
"deleted": { "$eq": 0 }
}
},
"resources": {
"customer": {
"defaults": {
"pageSize": 20
},
"actions": {
"list": {
"defaults": {
"currentPage": 1,
"orderBy": [{ "updated_at": "desc" }]
}
}
}
}
}
}datasetCode and datatable
data actions must be able to locate a dataset.
Prefer datasetCode:
{
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}To resolve by physical table name, use datatable or table:
{
"datatable": "crm_customer"
}Provide at least one of the two. datasetCode is the most explicit and must be a 32-character code. datatable is a good fit when the physical table is identical across environments but dataset codes may differ.
risk: marking the risk level
| Value | Use for |
|---|---|
read | Queries, detail views, statistics. The default. |
write | Creating, updating, syncing, triggering business actions. |
high-risk-write | Deletions, bulk replacements, irreversible operations. |
data actions infer some of the risk automatically:
filter,getOne,aggregatedefault toreadcreate,batchCreate,updatedefault towritedeletedefaults tohigh-risk-write
Backend Functions and SQL default to read. If the operation actually writes data, explicitly set risk: "write" or risk: "high-risk-write".
Upgrading from the legacy commands format
Legacy form:
{
"service": {
"code": "crm",
"name": "CRM"
},
"appBindings": {
"main": { "appcode": "app-xxxxxxxx" }
},
"commands": [
{
"path": "customer list",
"description": "查看客户列表",
"target": {
"kind": "data",
"command": "filter",
"appRef": "main",
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"flags": [
{
"name": "status",
"type": "string",
"mapTo": "where.status.$eq"
}
]
}
]
}New form:
{
"service": "crm",
"name": "CRM",
"apps": {
"main": { "appcode": "app-xxxxxxxx" }
},
"resources": {
"customer": {
"appRef": "main",
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"actions": {
"list": {
"description": "查看客户列表",
"flags": {
"status": "where.status.$eq"
}
}
}
}
}
}Field mapping:
| Legacy field | New field |
|---|---|
service.code | service |
appBindings | apps |
commands[].path | resources path + actions key |
commands[].target.datasetCode | datasetCode on the resource or action |
commands[].target.command | the action's action |
commands[].flags[] | the action's flags object |
commands[].mapTo | the action's map |
The legacy commands array can still be imported and executed. Prefer resources/actions in new configurations: it expresses the business object hierarchy naturally and avoids assembling commands with spaces inside JSON keys or path values.
Complete CRM example
{
"service": "crm",
"name": "CRM",
"description": "客户、联系人和跟进记录的业务服务",
"app": "app-xxxxxxxx",
"resources": {
"customer": {
"name": "客户",
"datasetCode": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"defaults": {
"currentPage": 1,
"pageSize": 20
},
"actions": {
"list": {
"description": "查看客户列表",
"defaults": {
"orderBy": [{ "updated_at": "desc" }]
},
"flags": {
"status": "where.status.$eq",
"keyword": {
"to": "where.customer_name",
"op": "$contain",
"description": "客户名称关键字"
},
"page": {
"to": "currentPage",
"type": "number",
"transform": "number"
},
"pageSize": {
"to": "pageSize",
"type": "number",
"transform": "number"
}
}
},
"mine": {
"description": "查看我负责的客户",
"map": {
"context.userId": {
"target": "where.owner_id",
"operator": "$eq",
"transform": "number"
}
}
},
"detail": {
"description": "查看客户详情",
"action": "getOne",
"args": ["id"],
"map": {
"id": {
"target": "id",
"transform": "number"
}
}
},
"create": {
"description": "创建客户",
"action": "bff.exec",
"scriptName": "createCustomer",
"risk": "write",
"flags": {
"customerName": {
"to": "customerName",
"required": true,
"description": "客户名称"
},
"industry": {
"to": "industry",
"description": "所属行业"
}
}
}
},
"resources": {
"contact": {
"name": "联系人",
"datasetCode": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"actions": {
"list": {
"description": "查看客户联系人",
"flags": {
"customerId": {
"to": "where.customer_id",
"op": "$eq",
"type": "number",
"transform": "number",
"required": true
}
}
}
}
}
}
}
}
}Generated business entry points:
lovrabet crm customer list
lovrabet crm customer mine
lovrabet crm customer detail 10001
lovrabet crm customer create --customer-name 云兔科技 --industry software
lovrabet crm customer contact list --customer-id 10001Validation checklist
Check each item before importing:
serviceis in lowercase kebab-case, for examplecrm,order,store-ops.- There is at least one
resourcesorcommands. - New configurations prefer
resources/actions. - Every resource key and action key is in lowercase kebab-case.
- No two generated full command paths are identical.
- data actions have a
datasetCodeordatatable/table. datasetCodeis a 32-character code.- SQL actions have a
sqlCode. - Backend Function actions have
bffCode,bffId, orscriptName. flags.typeonly usesstring,number,boolean, orjson.- Sources in
map/mapToonly useflags.,args.,context.,ctx.,const., or short names that auto-match existingargs/flags. - Write actions have the correct
riskset. - In multi-app services, every resource or action
appRefcan be found inapps.
When to keep using legacy commands
Only two situations justify staying with commands:
- You have a large body of existing configuration, only need small fixes in the short term, and don't want to restructure right away.
- A service is extremely simple, and your team has confirmed it doesn't need a resource hierarchy.
Prefer resources/actions for new configurations.