Skip to content
Syno Package Land
Language: English

Native apps

A native app is a Go backend that DSM runs as a CGI, plus a UI that opens in its own DSM window. It installs as an unsigned SPK, runs as its own package account, needs no containers and has no root access by default. synopkgland init -t native-go creates a working project: sign-in status, a greeting that administrators can edit, and the build wiring.

You need Node.js with the synopkgland CLI and Go 1.25 or later.

Terminal window
synopkgland init -t native-go my-app

The command refuses to overwrite files (INIT_TARGET_NOT_EMPTY). The default UI is Vue. Add --ui plain for a hand-written ui/app.js, see UI components. Add --with files,jobs,login for capabilities, see Capabilities.

PathWhat it isEdit it?
dsm-package.yamlPackage identity, version, build number, architectures, backend and UI locationYes
backend/internal/app/Your API: routes and handlersYes
backend/internal/api/permissions.jsonWho may call each methodYes
frontend/src/The Vue UI shell. Its text is in frontend/src/i18n/<locale>.jsonYes
i18n/messages/<locale>.jsonPackage text in nine languagesYes
i18n/glossary.jsonDSM’s own words and the messages that must use themAdd terms
AGENTS.md, CLAUDE.mdInstructions for an AI assistant working in the projectYes
backend/internal/api/permissions.go, backend/cmd/Permission loading and wiring of the CGI and the jobs workerRarely
dist/Built SPK filesNo

Request guards, the DSM sign-in check, File Station, jobs and delegate come from the SDK, not from project files: the Go module github.com/synopackageland/syno-pkg-land-sdk and the npm package @synopackageland/sdk. The require github.com/synopackageland/syno-pkg-land-sdk line in backend/go.mod is the project’s SDK version. frontend/package.json must pin @synopackageland/sdk to the same version, or check fails with SDK_VERSION_MISMATCH. A CLI builds only SDKs of its own minor version (SDK_UNSUPPORTED).

With --ui plain the project has ui/app.js and ui/app.css instead of frontend/. The build generates everything else: INFO, privileges (run-as: package), lifecycle scripts, icons, the window loader and the CGI.

To package a prebuilt executable instead of Go source, use -t binary. A binary backend cannot use files, jobs, login or delegate. To keep a product that already has its own payload and lifecycle, see Existing native products.

A route has two parts: a Go handler registered in backend/internal/app/routes.go, and an entry in backend/internal/api/permissions.json.

func Routes(cfg Config) []api.Route {
routes := cfg.delegateRoutes()
routes = append(routes, []api.Route{
api.Handle("app.status", cfg.status),
api.Handle("greeting.get", cfg.getGreeting),
api.Handle("greeting.set", cfg.setGreeting),
api.Handle("notes.list", cfg.listNotes),
}...)
// ...the rest of the function stays as generated
}
func (cfg Config) listNotes(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) {
var in struct {
Limit int `json:"limit"`
}
if err := decodeParams(params, &in); err != nil {
return nil, err
}
if in.Limit < 1 || in.Limit > 100 {
return nil, api.BadRequest("limit must be 1 to 100")
}
return map[string]any{"owner": caller.User(), "notes": []string{}}, nil
}
{
"schema": 1,
"default": "administrators",
"methods": {
"notes.list": "authenticated"
}
}

Then add valid parameters for the route to sampleParams in backend/internal/app/routes_test.go, for example the key notes.list with the value {"limit":20}. The generated tests fail until you do. Run them with (cd backend && go test ./...).

Rules for routes:

  • A handler is func(ctx, caller, params) (any, error). decodeParams rejects unknown fields. Return api.BadRequest(message) or &api.Error{Code, Message}. Any other error reaches the UI as internal.
  • Use api.HandleRequest when the handler needs the HTTP request, api.HandleResponse to set cookies or write the response, and api.HandleStream for a transfer.
  • caller.User() is the signed-in DSM account, caller.Admin() is true for members of the administrators group. Both come from DSM, never from the request.
  • Inside a route that ordinary users share, api.RequireAdmin(caller) reserves one action for administrators. It returns admin_required.
  • Method names are lowercase words joined by dots, for example notes.list. A name listed in permissions.json must be registered.
  • Changing permissions.json needs a rebuild. Requests cannot change it.
Level in permissions.jsonWithout a sessionOrdinary userAdministrator
administrators (also the result for an unlisted method)session_requiredadmin_requiredruns
authenticatedsession_requiredrunsruns
publicrunsrunsruns

default must stay administrators. Use public only for routes that must work without signing in. A route that is authenticated or public also lets ordinary users open the app window, see entry below.

The optional entry field in dsm-package.yaml adds a check before every route.

entryWho gets inRefusal code
omittedThe window follows your routes: it is visible to all users when at least one route, including those added by files or jobs, is not administratorsper route
admin-onlyAdministrators onlyadmin_required
app-privilegeAdministrators, plus accounts allowed in Control Panel > Application Privilegesapp_privilege_denied

app-privilege needs at least one authenticated route. public routes skip the check. If DSM cannot answer the privilege question, the call fails with auth_unavailable.

The UI receives a context object ctx. ctx.call(method, params) posts to your backend with the signed-in DSM session and resolves with the result. Errors reject with { code, message }.

try {
const { notes } = await ctx.call<{ notes: string[] }>('notes.list', { limit: 20 })
console.log(notes)
} catch (error) {
const { code } = error as { code?: string }
// show a message for `code`, for example admin_required or bad_request
}

Show administrator-only controls with ctx.user?.isAdmin. That only hides controls. The backend decides who may call. Other context members: ctx.reopen(fn) runs fn each time DSM shows the window again and returns an unsubscribe function, ctx.locale is the window language.

All replies use HTTP 200. A failure is {"success": false, "error": {"code", "message"}}, like DSM’s own web API. The error codes your UI sees are listed in Errors.

The app ships in nine languages: en, zh-TW, zh-CN, ja, ko, de, fr, es and it. DSM’s other languages show English. The window follows DSM’s language setting.

  • i18n/messages/<locale>.json holds flat "dotted.key": "text" pairs. en.json is required. Every language file needs exactly the keys of en.json and the same {placeholder} names.
  • To add a message, add the key to en.json and to the other eight files.
  • Add an error.<code> message for every error code your backend returns, so the UI can show text in the user’s language.
  • i18n/glossary.json lists DSM’s own words (“Save”, “Cancel”) in all nine languages and which messages must contain them. To use a DSM term, copy its text and add your key to the term’s keys.
  • i18n.info in dsm-package.yaml sets the display name and description that Package Center shows per language. English comes from the top-level fields.
  • In a plain UI, ctx.t("greeting.setBy", { text, user }) reads these files. The Vue UI keeps its on-screen text in frontend/src/i18n/<locale>.json. Add new text to all nine files there.

check and build fail on a missing or extra key, a dropped placeholder, an unsupported language, or a message that drifts from the glossary.

Terminal window
synopkgland check
synopkgland build
synopkgland deploy --host my-nas
synopkgland status --host my-nas
synopkgland logs --host my-nas
CommandWhat it does
checkValidates the manifest, capability combinations, permissions.json and translations
buildWrites dist/<package>-<version>-<build>-<arch>.spk for each architecture. In a Vue project, run npm install in frontend/ first
deployBuilds one architecture and installs it on the NAS. The version-build combination must be higher than the installed one (DEPLOY_VERSION_INVALID). Use --arch if the CPU type cannot be detected (DEPLOY_ARCH_UNDETERMINED)
statusPackage state, the URL that opens the app, and the readiness of each capability
logsPackage status lines and the package identity. It is not an application log

--host is required for deploy, status and logs (HOST_REQUIRED). Bump build for every SPK you hand out. See Ship for installing, upgrades and publishing. --json on check, build, deploy and status prints one JSON object.

ItemLimit
Package name2 to 64 letters and digits, starting with a letter
buildInteger from 1 to 9999
JSON request body1 MiB
Time for one request20 seconds
Architecturesx86_64, aarch64, armv7 (armv7 jobs, delegate, root operations and daemon are not yet verified on a NAS)
Minimum DSMos_min_ver defaults to 7.1-42661
LanguagesNine, see above
Symptom or codeCauseFix
admin_required for an ordinary userThe route is administrators or not listed in permissions.jsonOpen the app with an administrator account. If the method is intended for ordinary users, set "authenticated" for it and rebuild.
unknown_methodThe method is not registered in routes.goRegister it with api.Handle
bad_requestdecodeParams rejected an unknown field or the handler rejected a valueSend exactly the fields the handler reads
session_requiredNo valid DSM sessionSign in to DSM again
app_privilege_deniedentry: app-privilege and the account has no Application Privileges for the appAsk an administrator to open Control Panel > Application Privileges, select the app and click Edit, then allow your account or group.
INIT_TARGET_NOT_EMPTYinit found an existing fileUse an empty directory
Go tests fail after adding a routeThe route is missing from sampleParamsAdd valid parameters

An admin-only app is available only to members of the DSM administrators group. Open it with an administrator account; it is not listed in Application Privileges. For an app_privilege_denied refusal, use the app’s display name in the Application Privileges list. SDK denial views distinguish these two codes; other permission errors keep their own guidance.