diff --git a/astro.sidebar.ts b/astro.sidebar.ts
index 01c0c295ce..461acde40e 100644
--- a/astro.sidebar.ts
+++ b/astro.sidebar.ts
@@ -580,6 +580,16 @@ const apisAndSdksItems = (prefix: string) => [
`${prefix}/reference/python-client`,
],
},
+ {
+ label: 'Extensions',
+ collapsed: true,
+ items: [
+ `${prefix}/reference/extensions/overview`,
+ `${prefix}/reference/extensions/quickstart`,
+ `${prefix}/reference/extensions/architecture`,
+ `${prefix}/reference/extensions/development-workflow`,
+ ],
+ },
{
label: 'Mobile',
collapsed: true,
@@ -1784,6 +1794,7 @@ const mainSidebarItems = (
items: [
`${prefix}/user-guide/cli`,
`${prefix}/user-guide/cli-solutions`,
+ `${prefix}/user-guide/cli-extensions`,
],
},
`${prefix}/user-guide/ai-solution-creator`,
@@ -2198,6 +2209,7 @@ export const paasSidebar: SidebarConfig = [
items: [
'docs/paas/user-guide/cli',
'docs/paas/user-guide/cli-solutions',
+ 'docs/paas/user-guide/cli-extensions',
],
},
'docs/paas/user-guide/ai-solution-creator',
@@ -2597,6 +2609,7 @@ export const paasEuSidebar: SidebarConfig = [
items: [
'docs/paas/eu/user-guide/cli',
'docs/paas/eu/user-guide/cli-solutions',
+ 'docs/paas/eu/user-guide/cli-extensions',
],
},
'docs/paas/eu/user-guide/ai-solution-creator',
diff --git a/public/_redirects b/public/_redirects
index 21192c24a3..1530ecb047 100644
--- a/public/_redirects
+++ b/public/_redirects
@@ -101,6 +101,10 @@
/docs/user-guide/install/pe/edge/windows/ /docs/edge/pe/installation/docker-windows/ 301
# Single page redirects
+/docs/reference/extensions/cli-deployment/ /docs/user-guide/cli-extensions/ 301
+/docs/pe/reference/extensions/cli-deployment/ /docs/pe/user-guide/cli-extensions/ 301
+/docs/paas/reference/extensions/cli-deployment/ /docs/paas/user-guide/cli-extensions/ 301
+/docs/paas/eu/reference/extensions/cli-deployment/ /docs/paas/eu/user-guide/cli-extensions/ 301
/docs/iot-gateway/configuration/ /docs/iot-gateway/config/general/ 301
/docs/iot-gateway/how-device-removing-renaming-works/ /docs/iot-gateway/features/device-renaming/ 301
/docs/iot-gateway/guides/how-to-configure-gateway-using-configurator/ /docs/iot-gateway/config/general/ 301
diff --git a/public/redirects.json b/public/redirects.json
index 8c08db43bf..b09e6de3bb 100644
--- a/public/redirects.json
+++ b/public/redirects.json
@@ -226,6 +226,10 @@
"/docs/user-guide/install/pe/edge/rpi/": "/docs/edge/pe/installation/rpi/",
"/docs/user-guide/install/pe/edge/upgrade-instructions/": "/docs/edge/pe/installation/upgrade-instructions/",
"/docs/user-guide/install/pe/edge/windows/": "/docs/edge/pe/installation/docker-windows/",
+ "/docs/reference/extensions/cli-deployment/": "/docs/user-guide/cli-extensions/",
+ "/docs/pe/reference/extensions/cli-deployment/": "/docs/pe/user-guide/cli-extensions/",
+ "/docs/paas/reference/extensions/cli-deployment/": "/docs/paas/user-guide/cli-extensions/",
+ "/docs/paas/eu/reference/extensions/cli-deployment/": "/docs/paas/eu/user-guide/cli-extensions/",
"/docs/iot-gateway/configuration/": "/docs/iot-gateway/config/general/",
"/docs/iot-gateway/how-device-removing-renaming-works/": "/docs/iot-gateway/features/device-renaming/",
"/docs/iot-gateway/guides/how-to-configure-gateway-using-configurator/": "/docs/iot-gateway/config/general/",
diff --git a/src/assets/images/reference/extensions/assign-profile-dialog.png b/src/assets/images/reference/extensions/assign-profile-dialog.png
new file mode 100644
index 0000000000..f0fa28d782
Binary files /dev/null and b/src/assets/images/reference/extensions/assign-profile-dialog.png differ
diff --git a/src/assets/images/reference/extensions/extension-details.png b/src/assets/images/reference/extensions/extension-details.png
new file mode 100644
index 0000000000..80f3bd7c9f
Binary files /dev/null and b/src/assets/images/reference/extensions/extension-details.png differ
diff --git a/src/assets/images/reference/extensions/extension-logs.png b/src/assets/images/reference/extensions/extension-logs.png
new file mode 100644
index 0000000000..d07956d9f0
Binary files /dev/null and b/src/assets/images/reference/extensions/extension-logs.png differ
diff --git a/src/assets/images/reference/extensions/extension-swagger-ui.png b/src/assets/images/reference/extensions/extension-swagger-ui.png
new file mode 100644
index 0000000000..ac1d65e956
Binary files /dev/null and b/src/assets/images/reference/extensions/extension-swagger-ui.png differ
diff --git a/src/assets/images/reference/extensions/extensions-list.png b/src/assets/images/reference/extensions/extensions-list.png
new file mode 100644
index 0000000000..012715430f
Binary files /dev/null and b/src/assets/images/reference/extensions/extensions-list.png differ
diff --git a/src/assets/images/reference/extensions/technician-user-view.png b/src/assets/images/reference/extensions/technician-user-view.png
new file mode 100644
index 0000000000..d6185a7a72
Binary files /dev/null and b/src/assets/images/reference/extensions/technician-user-view.png differ
diff --git a/src/assets/images/reference/extensions/technicians-group-members.png b/src/assets/images/reference/extensions/technicians-group-members.png
new file mode 100644
index 0000000000..0253940450
Binary files /dev/null and b/src/assets/images/reference/extensions/technicians-group-members.png differ
diff --git a/src/assets/images/reference/extensions/tenant-profile-extension-limits.png b/src/assets/images/reference/extensions/tenant-profile-extension-limits.png
new file mode 100644
index 0000000000..3dcad5f010
Binary files /dev/null and b/src/assets/images/reference/extensions/tenant-profile-extension-limits.png differ
diff --git a/src/assets/images/reference/extensions/user-groups-created.png b/src/assets/images/reference/extensions/user-groups-created.png
new file mode 100644
index 0000000000..5e89b77858
Binary files /dev/null and b/src/assets/images/reference/extensions/user-groups-created.png differ
diff --git a/src/assets/images/reference/extensions/user-profile-dashboard.png b/src/assets/images/reference/extensions/user-profile-dashboard.png
new file mode 100644
index 0000000000..4c1c3eb996
Binary files /dev/null and b/src/assets/images/reference/extensions/user-profile-dashboard.png differ
diff --git a/src/assets/schemas/extension-deployment-options-dark.svg b/src/assets/schemas/extension-deployment-options-dark.svg
new file mode 100644
index 0000000000..32e150e968
--- /dev/null
+++ b/src/assets/schemas/extension-deployment-options-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-deployment-options.svg b/src/assets/schemas/extension-deployment-options.svg
new file mode 100644
index 0000000000..95181eb016
--- /dev/null
+++ b/src/assets/schemas/extension-deployment-options.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-dev-workflow-dark.svg b/src/assets/schemas/extension-dev-workflow-dark.svg
new file mode 100644
index 0000000000..e4a0ad09aa
--- /dev/null
+++ b/src/assets/schemas/extension-dev-workflow-dark.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-dev-workflow.svg b/src/assets/schemas/extension-dev-workflow.svg
new file mode 100644
index 0000000000..cc2135cbcd
--- /dev/null
+++ b/src/assets/schemas/extension-dev-workflow.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-lifecycle-dark.svg b/src/assets/schemas/extension-lifecycle-dark.svg
new file mode 100644
index 0000000000..da8468e619
--- /dev/null
+++ b/src/assets/schemas/extension-lifecycle-dark.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-lifecycle.svg b/src/assets/schemas/extension-lifecycle.svg
new file mode 100644
index 0000000000..6eb538fa6d
--- /dev/null
+++ b/src/assets/schemas/extension-lifecycle.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-mode-private-cloud-dark.svg b/src/assets/schemas/extension-mode-private-cloud-dark.svg
new file mode 100644
index 0000000000..3ed48906e2
--- /dev/null
+++ b/src/assets/schemas/extension-mode-private-cloud-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-mode-private-cloud.svg b/src/assets/schemas/extension-mode-private-cloud.svg
new file mode 100644
index 0000000000..3d7c6d230e
--- /dev/null
+++ b/src/assets/schemas/extension-mode-private-cloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-mode-public-cloud-dark.svg b/src/assets/schemas/extension-mode-public-cloud-dark.svg
new file mode 100644
index 0000000000..35ea3096c7
--- /dev/null
+++ b/src/assets/schemas/extension-mode-public-cloud-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-mode-public-cloud.svg b/src/assets/schemas/extension-mode-public-cloud.svg
new file mode 100644
index 0000000000..537b1c62cf
--- /dev/null
+++ b/src/assets/schemas/extension-mode-public-cloud.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/schemas/extension-mode-self-hosted-dark.svg b/src/assets/schemas/extension-mode-self-hosted-dark.svg
new file mode 100644
index 0000000000..f286c99562
--- /dev/null
+++ b/src/assets/schemas/extension-mode-self-hosted-dark.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-mode-self-hosted.svg b/src/assets/schemas/extension-mode-self-hosted.svg
new file mode 100644
index 0000000000..1c2b520a48
--- /dev/null
+++ b/src/assets/schemas/extension-mode-self-hosted.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-request-flow-dark.svg b/src/assets/schemas/extension-request-flow-dark.svg
new file mode 100644
index 0000000000..ed9b6bb479
--- /dev/null
+++ b/src/assets/schemas/extension-request-flow-dark.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/schemas/extension-request-flow.svg b/src/assets/schemas/extension-request-flow.svg
new file mode 100644
index 0000000000..d76aaf7504
--- /dev/null
+++ b/src/assets/schemas/extension-request-flow.svg
@@ -0,0 +1 @@
+
diff --git a/src/content/_includes/docs/reference/apis-and-sdks.mdx b/src/content/_includes/docs/reference/apis-and-sdks.mdx
index c528a526aa..44c7911fa5 100644
--- a/src/content/_includes/docs/reference/apis-and-sdks.mdx
+++ b/src/content/_includes/docs/reference/apis-and-sdks.mdx
@@ -48,6 +48,14 @@ import DocLink from '@components/DocLink.astro';
- Java REST Client
- Python REST Client
+
+
+ - Overview
+ - Quickstart
+ - Architecture
+ - Development workflow
+ - Deploy with the CLI
+
## Mobile apps
diff --git a/src/content/_includes/docs/reference/extensions/architecture.mdx b/src/content/_includes/docs/reference/extensions/architecture.mdx
new file mode 100644
index 0000000000..aa45c3c61e
--- /dev/null
+++ b/src/content/_includes/docs/reference/extensions/architecture.mdx
@@ -0,0 +1,171 @@
+import { Aside } from '@astrojs/starlight/components';
+import DocLink from '@components/DocLink.astro';
+import ImageGallery from '@components/ImageGallery.astro';
+import Banner from '~/components/Banner.astro';
+import { Products } from '~/models/site.models';
+
+Extensions work only with ThingsBoard Professional and ThingsBoard Cloud version **4.4.0** or later.
+
+{props.product !== Products.CE && (
+ Available from ThingsBoard PE/Cloud 4.4.0 and later.
+)}
+
+This page explains how an extension works inside ThingsBoard: how a request reaches your code, how callers are checked, and where the container can run. Start with the Extensions overview if you have not read it yet.
+
+## How it works
+
+An extension is a normal web service. It listens on port **8090** (the default) and serves its endpoints at its own root — for example `/userProfile/...`. In the managed mode you do not run or scale it yourself: you give ThingsBoard the image, and ThingsBoard creates the container, keeps it running, and routes traffic to it. (You can also run the container yourself — see [Where extensions run](#where-extensions-run).)
+
+
+
+Three parts work together:
+
+1. **The extension entity.** Each extension is a tenant entity in ThingsBoard, like a device or an asset. It holds the container spec — the image, tag, number of replicas, port, environment variables, and resource size. When you save it, ThingsBoard pulls the image and starts the container. ThingsBoard watches the container and restarts it if it fails.
+2. **The router.** Clients never call the container directly. They call ThingsBoard at `{BASE_URL}/api/extension/route/{slug}/...`. In front of the containers sits a reverse proxy — the **router**. It reads the slug from the URL, strips the `/api/extension/route/{slug}` prefix, and forwards the rest to that extension's container: a call to `.../route/{slug}/userProfile/name/Technician` arrives as `/userProfile/name/Technician`. It also passes the stripped prefix in an `X-Forwarded-Prefix` header, which is how the extension builds correct absolute URLs on its Swagger and OpenAPI pages.
+3. **The auth gateway.** Before it forwards anything, the router makes its own HTTP request to ThingsBoard — `GET /api/ext-auth` — and passes the slug together with the caller's `X-Authorization` header. ThingsBoard answers **200** only when the credential is valid **and** the caller's tenant owns that slug; otherwise it answers **403**. The router forwards the original request only after a 200, so a rejected call never reaches your code. The check is fail-closed: when ThingsBoard is unreachable or answers an error, the call is rejected too. This is why, in the managed mode, your service does not have to check tokens itself.
+
+All three parts are tenant-scoped. The entity belongs to one tenant, the router only accepts callers of that tenant, and the container serves that tenant alone. You can see the entity in the ThingsBoard UI under **Data processing → Extensions** — the Extensions overview shows the page.
+
+### The slug
+
+Every extension gets a **slug** — a short, unique name used in its URL. You choose a **slug base** (like `acme-billing`), and the installation decides what the final slug looks like:
+
+- **ThingsBoard Cloud** always appends a random four-character suffix — `acme-billing-9f3a`. Many tenants share one slug space there, so the suffix keeps a slug both unique and hard to guess.
+- **A private installation** keeps your exact base by default — the slug is `acme-billing`. Because there is nothing to disambiguate a second extension with the same base, a base already in use is refused with a clear error. An operator can still turn the suffix on with `extensions.slug.enforce-suffix`, so do not treat "no suffix" as a rule you can build on.
+
+The slug never changes after the extension is created.
+
+Because the instance decides the slug, a dashboard or a script must not hard-code it. Ask for it by the extension **name**, which is the same everywhere:
+
+```http
+GET /api/extension/slug?name=user-profiles-demo -> {"slug": "user-profiles-demo-9f3a"}
+```
+
+The endpoint is tenant-scoped and open to any signed-in user of the tenant, so a widget can resolve the name once when the dashboard loads and then build every URL from the answer. That is what makes an exported dashboard importable on another instance.
+
+### Calling ThingsBoard back
+
+Your extension often needs to read or write ThingsBoard data. It does this with the **ThingsBoard REST client** for your language — Python (`tb-pe-client`) or Java (`thingsboard-pe-client`); each page shows the exact package for your edition. The client uses the **caller's own credential**, so every call respects that user's tenant and permissions. You do not store an admin password inside the extension.
+
+## Extension states
+
+A managed extension moves through a few states. `tb extension deployments status` prints the current one, and the same value is shown in the ThingsBoard UI under **Data processing → Extensions**.
+
+
+
+- **Provisioning** — ThingsBoard is pulling the image and starting the container.
+- **Active** — the container is running and ready to serve requests.
+- **Updating** — a new image or spec is rolling out; the replicas that are already ready keep serving.
+- **Degraded** — the extension runs, but some replicas are not ready.
+- **Stopped** — you stopped it, and ThingsBoard scaled it to zero replicas; the entity stays, but nothing runs.
+- **Failed** — the container could not start, or it crashed while running (for example, out of memory or a crash loop); the reason is shown as the last error.
+
+Click the extension row in the UI to see the state, the ready replicas, the time of the last status check, and the container configuration:
+
+
+
+The state comes from a background health check, so a change can take a short time to appear. A **Failed** extension keeps its entity and its slug — fix the image or the spec and deploy again. These states exist only in the managed modes; in the self-hosted mode there is no extension entity, so nothing tracks the container for you.
+
+## Authentication
+
+An incoming request carries an `X-Authorization` header. There are three ways an extension is used:
+
+| Mode | Header | Typical use |
+|------|--------|-------------|
+| **API key** | `X-Authorization: ApiKey ` | Rule chain callbacks |
+| **JWT** | `X-Authorization: Bearer ` | Dashboard widget buttons (the user's session token) |
+| **Configured** | *(none — set at startup)* | Scheduled background jobs |
+
+For API key and JWT, the extension acts as the caller. For scheduled jobs there is no caller, so you set a service credential (`TB_AUTH_API_KEY`, or a username and password) as an environment variable.
+
+
+
+### How the public docs path is kept safe
+
+That carve-out is the only way into an extension without a credential, so the router guards it in two ways. Both matter when you plan your own paths.
+
+**No path traversal.** The router refuses any request whose URL contains `..`, an encoded dot, slash, or backslash (`%2e`, `%2f`, `%5c`), or a double-encoded character (`%25`). Such a request gets **404** and never reaches the container. Without this rule, a caller could write `/swagger-ui/../userProfile` — a URL that matches the public docs pattern, but that a server which normalizes `..` would turn into a protected endpoint. Because of the rule, the unauthenticated path cannot be used to smuggle a request into an authenticated one.
+
+**No slug oracle.** An unknown slug cannot be resolved, so the container is never reached and the router would normally answer 502 or 504. It answers **404** instead. A real slug you are not allowed to see and a slug that was never created therefore look exactly alike, and nobody can use the public docs path to discover which extensions exist.
+
+Both rules apply to the docs paths only. Every other path already needs a valid credential, so it is rejected before any of this matters.
+
+
+
+## Where extensions run
+
+The same extension image can run in three environments. In the first two, ThingsBoard is **managed** — it runs and scales the container for you. In the third, you run the container yourself.
+
+
+
+| | Public cloud | Private cloud | Self-hosted |
+|---|---|---|---|
+| **Where** | [ThingsBoard Cloud](/installations/?product=thingsboard-cloud) | Your own ThingsBoard PE | Any host you manage |
+| **Where the container lands** | A separate extensions cluster | The same cluster, a separate zone — or a single Docker host | Your host |
+| **Who runs the container** | ThingsBoard | ThingsBoard | You |
+| **How to deploy** | CLI or REST API | CLI or REST API | Docker Compose (`deploy/` folder) |
+| **Routing and auth** | Built in | Built in | You set it up |
+| **Scaling and restarts** | Automatic | Automatic | Manual |
+
+### Public cloud — ThingsBoard Cloud
+
+ThingsBoard Cloud runs the container on shared cloud infrastructure. This is the fastest way to get an extension online: you push the image and deploy — there is nothing to install or operate.
+
+**Two clusters.** ThingsBoard Cloud keeps the platform and the extensions apart. The platform — the UI, the REST API, and the router — runs in one cluster. Extension containers run in a second, separate cluster. Your code never runs next to the platform services, and the router is the only way in. The extensions cluster pulls your image from the registry itself.
+
+
+
+Points to know:
+
+- The slug always ends with a random suffix (for example `acme-billing-9f3a`), so it is unique across all tenants. Read it from the deploy output, or resolve it by name — never type it into a dashboard or a script.
+- The image registry must be reachable from the internet. For a private registry, add pull credentials — the registry flags of the CLI, or `registryAuth` in the deployment API request.
+- CPU and memory are limited by the **resource size** you pick (`tb extension sizes` lists the options).
+
+### Private cloud — your own ThingsBoard PE
+
+A ThingsBoard PE installation (version **4.4.0** or later) offers the same managed experience on your own infrastructure. The workflow does not change: the same CLI commands and the same REST API — only your profile points at your own instance instead of `thingsboard.cloud`. One small difference: the slug keeps your exact slug base, with no random suffix, unless the operator turns the suffix on.
+
+**Two private modes.** ThingsBoard can run your containers on Kubernetes or on a single Docker host. The same image, the same CLI, the same router — only the substrate differs:
+
+| `extensions.mode` | Where the container runs | Replicas | Use it for |
+|---|---|---|---|
+| `private-cloud` | your Kubernetes cluster — the one ThingsBoard already runs in, in a separate namespace with its own resource limits | as many as you ask for | production |
+| `docker-compose` | one Docker host, one container per extension | always one, whatever number you ask for — a single Docker host has no load balancer behind a container name | a small installation, a demo, or a test environment |
+
+**One cluster, a separate zone.** In `private-cloud` mode you do not need a second cluster. ThingsBoard PE and the extension containers share **one** cluster, and extensions get their own zone inside it — a separate namespace with its own resource limits. The platform starts, scales, and restarts the containers in that zone, and the router still checks every caller before a request reaches them. This is the mode in the picture below.
+
+
+
+Use this mode when your data must stay inside your network, or when the extension needs to reach internal systems that are not visible from the public cloud. The image registry only has to be reachable from your PE installation, so an internal registry works.
+
+One setup step is needed first: on a PE installation, managed extensions are **off by default**. A system administrator picks one of the two modes above with the `TB_EXTENSIONS_MODE` environment variable (`extensions.mode` in `thingsboard.yml`). While the mode is `disabled`, the `/api/extension` endpoints do not exist, and the CLI reports that the deployment API is not available. ThingsBoard Cloud runs in a third mode, `public-cloud`, and has the feature enabled already.
+
+
+
+### Self-hosted — Docker Compose
+
+You can also run the extension container yourself, next to your ThingsBoard instance (PE or Cloud). The starter project that `tb extension new` creates includes a `deploy/` folder with a ready Docker Compose setup and an HAProxy config snippet.
+
+
+
+In this mode there is no extension entity in ThingsBoard, so nothing is managed for you:
+
+- You start, update, and restart the container yourself.
+- You route the extension path to the container — for example with a reverse proxy.
+- The service still checks callers against your ThingsBoard instance, so authentication keeps working the same way.
+
+Choose this mode for local development, or when you want full control over how and where the container runs. For the details, follow the `deploy/` guide inside the starter project.
+
+The other pages in this section — the Quickstart and the CLI reference — describe the **managed** path.
+
+## Next steps
+
+- Quickstart — deploy a working extension with one command and see these parts in action.
+- Development workflow — the full development loop: plan, code, test, deploy, and ship updates.
+- Complete example: User Profiles — the extension behind the Quickstart: what it does, how to build your own version of it, and where its code lives.
diff --git a/src/content/_includes/docs/reference/extensions/development-workflow.mdx b/src/content/_includes/docs/reference/extensions/development-workflow.mdx
new file mode 100644
index 0000000000..415ba96a00
--- /dev/null
+++ b/src/content/_includes/docs/reference/extensions/development-workflow.mdx
@@ -0,0 +1,389 @@
+import { Aside, Steps } from '@astrojs/starlight/components';
+import Tabs from '@components/Tabs.astro';
+import TabItem from '@components/TabItem.astro';
+import DocLink from '@components/DocLink.astro';
+import ImageGallery from '@components/ImageGallery.astro';
+import HostCode from '~/components/HostCode.astro';
+import Banner from '~/components/Banner.astro';
+import { Products } from '~/models/site.models';
+
+Extensions work only with ThingsBoard Professional and ThingsBoard Cloud version **4.4.0** or later.
+
+{props.product !== Products.CE && (
+ Available from ThingsBoard PE/Cloud 4.4.0 and later.
+)}
+
+The Quickstart deploys a ready extension in five minutes. This page is about building your own: how to plan the feature, generate the code, test it on your machine, deploy it, and ship updates after the first release.
+
+One example runs through the whole page: **User Profiles**, the extension that ships with the ThingsBoard CLI. It adds reusable access templates. A profile — "Manager", "Technician", "Auditor" — names a whole set of rights: a user group, shared dashboards and devices, roles, and a default start page. One call gives a user that set; one call takes it back.
+
+
+
+
+
+## Before you start
+
+Unlike the Quickstart demo, a project of your own needs a build toolchain and a place to publish the image:
+
+- The ThingsBoard CLI, with a profile that points at your ThingsBoard Cloud or PE instance:
+
+ \ntb config use mytb`} />
+
+ An API key for your tenant is enough. The profile also decides **where** the extension is deployed — ThingsBoard Cloud or your own PE installation.
+- **Docker**, installed and running — the CLI builds the image with it.
+- A **container registry** you can push to (Docker Hub, GitHub Container Registry, or a private one). ThingsBoard must be able to pull from it; for a private registry, pass pull credentials at deploy time — see Private registry. Log in on your machine first (for example `docker login`): `tb extension push` uses your local Docker credentials and does not log in for you.
+
+The steps below work the same for **Java** and **Python** — only the first command changes — and the same on ThingsBoard Cloud and on your own PE.
+
+## Plan the feature first
+
+Write a short plan before you generate any code. Five lines are enough. For the User Profiles extension:
+
+- **Endpoints:** `GET /userProfiles` lists the profiles as a page; `POST /userProfile/name/{name}/assign/{userId}` applies one to a user; `DELETE` on that same path takes it back.
+- **Data:** the profiles live as `SERVER_SCOPE` attributes of one tenant asset called `user-profiles`. Assign also writes a `userProfile` attribute on the user, so a dashboard table can show the current profile in a column.
+- **Callers:** a dashboard button (JWT) and a rule chain node or an HR webhook (API key).
+- **Rules:** resolve every name to an id **before** the first write; undo what was already written when a later write fails.
+- **Not needed:** no external service, no scheduled task, no environment variables.
+
+Decide the endpoint paths now. Dashboards and rule chains will call these URLs, so renaming them later means updating every caller. Your endpoints live at the service root; the router adds the public `{BASE_URL}/api/extension/route/{slug}` prefix in front of them.
+
+**Shape them like the ThingsBoard API.** Your extension sits next to the platform API. Someone who knows `/api/device` should be able to guess your endpoints:
+
+| Action | Use | The ThingsBoard endpoint to copy |
+|---|---|---|
+| Create **or** update | `POST /` — the whole object in the body | `POST /api/device` |
+| Read one | `GET //{id}` | `GET /api/device/{deviceId}` |
+| Read a list | `GET /s?pageSize=&page=` → a `PageData` | `GET /api/tenant/devices` |
+| Delete | `DELETE //{id}` | `DELETE /api/device/{deviceId}` |
+| Link A to B | `POST //{id}/assign/{targetId}` | `POST /api/alarm/{alarmId}/assign/{assigneeId}` |
+| Unlink A from B | `DELETE //{id}/assign/{targetId}` | `DELETE /api/alarm/{alarmId}/assign` |
+
+Three rules come from that table. All three are easy to get wrong:
+
+- **Do not use `PUT` to save an object.** ThingsBoard saves with `POST` and puts the object in the body. It has only a few `PUT` endpoints, and each one writes a **single field** of an object that already exists, like `PUT /api/secret/{id}/value`.
+- **Do not add a verb to the path** when the HTTP method already says it. `POST /thing/{id}/unassign/{userId}` is wrong. `DELETE /thing/{id}/assign/{userId}` is right.
+- **Use camelCase path segments** — `userProfile`, not `user-profile` — matching `deviceProfile`, `entityGroup`, and `schedulerEvent`.
+
+Lists return ThingsBoard's `PageData`: `{"data": [], "totalPages": 0, "totalElements": 0, "hasNext": false}`. They also take `pageSize` and `page` as required query parameters. A dashboard table can then read the answer directly.
+
+Plan the moving parts of your own feature the same way. A scheduled task needs a service credential in an environment variable. An external API has rate limits, so a short interval with many devices can exhaust a free quota.
+
+## Create the project
+
+Run this inside a ThingsBoard project — `tb init` in an empty folder creates one (`mkdir user-profile && cd user-profile && tb init`). One command then unpacks a ready-to-run service with Docker files and authentication into an `extension/` folder. All file paths on this page are relative to that folder:
+
+
+
+ ```bash
+ tb extension new --lang java --name user-profile
+ ```
+
+ A Spring Boot project. Your code goes into `extension/src/main/java/` as `@RestController` classes and `@Service` components.
+
+
+ ```bash
+ tb extension new --lang python --name user-profile
+ ```
+
+ A FastAPI project. Your code goes into `app/extension/` as routers; background loops start from the app lifespan.
+
+
+
+`--name` sets the local image name. You can change it later with `tb extension rename`.
+
+Check that the starter runs before you add anything to it:
+
+```bash
+tb extension build
+tb extension run -d
+curl http://localhost:8090/api/health
+tb extension stop
+```
+
+`build` produces a local Docker image tagged with your extension name, targeting `linux/amd64` by default, so the image runs on common Linux hosts.
+
+`run -d` starts the container in the background and gives the prompt back. Plain `tb extension run` keeps the terminal, tails the container log until you press Ctrl-C, and shuts the container down when you do — good for watching one request, wrong for a script.
+
+
+
+What you get is deliberately empty of business logic: `GET /api/health`, the authentication plumbing, the error contract, the Docker and deploy files, and a test suite that already passes. There is no sample feature to delete before you start.
+
+The starter's README lists the four **auth patterns** — which header each kind of caller sends, and how to write the endpoint for it. Pick one before you write anything; it decides whether your endpoint takes a client argument, and which role check guards it. The full rules the generated code must follow — the REST shape, the `PageData` list format, the shared error body — live in `.claude/extension-conventions.md`, which `tb init` puts in the project.
+
+## AI-assisted development (optional)
+
+This section is a shortcut, not a step. Writing the endpoints yourself in your usual editor is a first-class path: the starter, the tests, and the whole `build → run → push → deploy` loop below work the same either way. Skip to Test on your machine if you write the code by hand.
+
+If you do want the help: `tb init` puts a `.claude/` folder in the project with skills and agents that know the extension starter — its layout, its authentication contract, and the ThingsBoard client API. Open the project in Claude Code and describe the feature in plain English:
+
+```bash
+cd user-profile
+claude
+```
+
+```text
+> /create-extension Store named access profiles as SERVER_SCOPE attributes of
+ a tenant asset. A profile names a user group, shared dashboard and device
+ groups, roles, and a default dashboard. Add endpoints to list the profiles,
+ to apply one to a user, and to take it back. Resolve every name to an id
+ before the first write, and undo what was already written when a later
+ write fails.
+```
+
+Your plan from the previous section is the prompt. Write it in plain English — the endpoints, the data, the callers, and the rules — and leave the framework details out. The skill knows them.
+
+What happens next:
+
+
+
+1. **The skill prepares the project.** It checks that you are inside a `tb` project, resolves the edition (PE for a self-hosted instance, PaaS for ThingsBoard Cloud) from your CLI profile, and runs `tb extension new` when the `extension/` folder does not exist yet. Add `--lang python` to your request if you want the Python starter instead of the Java default.
+
+2. **The `extension-builder` agent writes the code.** It reads the starter conventions, asks about the business details it cannot guess (entity names, error codes, who may call what), then generates the endpoints, the service class, and the tests. It builds the project and fixes its own compile errors before returning.
+
+3. **You get the auth setup.** The skill prints exactly what the feature needs to run — an API key for a rule-chain caller, nothing for a dashboard widget, or `TB_AUTH_API_KEY` in `deploy/.env` for a scheduled task.
+
+4. **You review, test, and deploy.** The result is normal source code in your project. Read it, run the tests, then continue with the `build → run → push → deploy` loop below.
+
+
+
+### What the generated code looks like
+
+The generated code is ordinary Spring Boot or FastAPI code. The endpoints of the User Profiles extension look like this:
+
+
+
+ ```java
+ // extension/src/main/java/org/thingsboard/extension/userprofile/UserProfileController.java
+ @RestController
+ public class UserProfileController {
+
+ private final UserProfileService service;
+
+ public UserProfileController(UserProfileService service) {
+ this.service = service;
+ }
+
+ /** List profiles as a ThingsBoard PageData. pageSize and page are mandatory. */
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @GetMapping("/userProfiles")
+ public Map listProfiles(@RequestParam int pageSize,
+ @RequestParam int page,
+ @RequestParam(required = false) String textSearch,
+ ThingsboardClient tb) throws ApiException {
+ return service.listProfiles(tb, pageSize, page, textSearch);
+ }
+
+ /** Apply the profile to a user: read, resolve names, write + record. */
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @PostMapping("/userProfile/name/{name}/assign/{userId}")
+ public Map assign(@PathVariable String name, @PathVariable String userId,
+ @RequestParam(defaultValue = "false") boolean force,
+ ThingsboardClient tb) throws ApiException {
+ return service.assign(tb, name, userId, force);
+ }
+ }
+ ```
+
+
+ ```python
+ # app/extension/user_profile.py
+ from typing import Annotated
+
+ from fastapi import APIRouter, Depends
+ from tb_pe_client.client import ThingsboardClient
+
+ from app.security import require_authority
+
+ router = APIRouter()
+
+ TbClient = Annotated[ThingsboardClient, Depends(require_authority("TENANT_ADMIN"))]
+
+
+ @router.get("/userProfiles")
+ def list_profiles(client: TbClient, pageSize: int, page: int, textSearch: str | None = None) -> dict:
+ ...
+
+
+ @router.post("/userProfile/name/{name}/assign/{user_id}")
+ def assign_profile(name: str, user_id: str, client: TbClient, force: bool = False) -> dict:
+ ...
+ ```
+
+ The router is registered in `app/extension/__init__.py`:
+
+ ```python
+ from app.extension import user_profile
+ router.include_router(user_profile.router)
+ ```
+
+
+
+### Review what you got
+
+The code is yours now, so read it before you deploy:
+
+- The endpoint paths match your plan. Changing them later means changing every caller.
+- The error codes are the ones you want. The generated tests show them.
+- The tests pass: `mvn test` (Java) or `pytest` (Python). Keep them green while you work — they catch broken authentication and routing early.
+
+
+
+## Four rules every extension follows
+
+These hold whether you generated the code or wrote it yourself:
+
+- **The client acts as the caller.** The `ThingsboardClient` argument is resolved per request from the caller's `X-Authorization` header. Every read and write it makes respects that user's tenant and permissions, so you never store an admin password inside the extension.
+- **Paths are relative to the service root.** `POST /userProfile/name/{name}/assign/{userId}` is served publicly as `{BASE_URL}/api/extension/route/{slug}/userProfile/name/{name}/assign/{userId}`.
+- **The caller can be narrowed further.** The router already checks that the caller belongs to the tenant that owns the extension. `@PreAuthorize` in Java and `require_authority` in Python add your own rule on top — for example, tenant administrators only.
+- **Errors already use the ThingsBoard body.** Both starters answer every failure with `{"status": …, "message": …, "errorCode": …, "timestamp": …}`. This is the same shape the platform API uses. It covers login failures, bad request values, and errors that came back from ThingsBoard itself. To report an error, raise `ResponseStatusException` (Java) or `HTTPException` (Python) and let the starter build the body. Do not add your own error handler. With two error shapes, a widget reads one field and loses the message for every error that took the other path.
+
+
+
+## Test on your machine
+
+This is the fast inner loop from the diagram. One thing to set up first: the local container checks credentials and reads data from a **real ThingsBoard instance**. Copy `.env.example` to `.env` in the `extension/` folder and set `THINGSBOARD_URL` (for example `https://thingsboard.cloud`). The default, `http://host.docker.internal:8080`, works only when ThingsBoard runs on your own machine.
+
+Then repeat the loop until the feature works:
+
+
+
+1. **Build and run the container locally.**
+
+ ```bash
+ tb extension build
+ tb extension run -d
+ ```
+
+2. **Call your endpoint.** Locally there is no router in front, so use the path as your code defines it:
+
+ ```bash
+ curl -X POST http://localhost:8090/userProfile/name/Technician/assign/ \
+ -H 'X-Authorization: Bearer '
+ ```
+
+ To get a JWT, call `POST {BASE_URL}/api/auth/login` with your username and password. An API key works too: `-H 'X-Authorization: ApiKey '`.
+
+3. **Read the logs, fix the code, and go back to step 1.**
+
+ ```bash
+ tb extension logs
+ ```
+
+ Small fixes are quicker by hand. For a bigger change, describe it to Claude Code again — the project and its `.claude/` skills are still there.
+
+
+
+Stop the local container with `tb extension stop` when you are done.
+
+## Deploy for the first time
+
+When the feature works locally, release it. Give the image a real version tag from day one — updates will be much easier to track than with `latest`:
+
+
+
+1. **Push the image to your registry.**
+
+ ```bash
+ tb extension build --image-tag 1.0.0
+ tb extension push --image-name myorg/user-profile --image-tag 1.0.0
+ ```
+
+ ThingsBoard runs the image from a registry, not from your machine. Use the full publish target, including any registry or organization prefix — `myorg/user-profile` or `registry.example.com/team/user-profile`.
+
+2. **Deploy it.**
+
+ ```bash
+ tb extension deploy --image-name myorg/user-profile --image-tag 1.0.0
+ ```
+
+ The CLI waits until the extension is `ACTIVE` and prints its URL and slug (for example `user-profile-9f3a`).
+
+ This feature needs no environment variables: every endpoint uses the caller's own credential. A feature with a scheduled task does — pass the service credential with `--env TB_AUTH_API_KEY=`, and any tuning value the same way.
+
+3. **Verify.**
+
+ ```bash
+ tb extension deployments status user-profile-9f3a
+ tb extension deployments logs user-profile-9f3a
+ ```
+
+
+
+ Open the API docs at `{BASE_URL}/api/extension/route/user-profile-9f3a/swagger-ui/index.html` (Java) or `{BASE_URL}/api/extension/route/user-profile-9f3a/docs` (Python) and try the endpoints.
+
+ The docs page itself opens without a token — it is public on purpose. Calling any endpoint from it still needs a valid credential in the `X-Authorization` header.
+
+ Then connect the real callers. In a rule chain, add a **REST API call** node with the URL `{BASE_URL}/api/extension/route/user-profile-9f3a/userProfile/name/Technician/assign/{userId}` and the header `X-Authorization: ApiKey `. For a dashboard button, call the same URL with the current user's session token: `X-Authorization: Bearer `. Every call must carry this header — the router rejects requests without a valid credential.
+
+4. **Remove it when you no longer need it (optional).**
+
+ ```bash
+ tb extension undeploy user-profile-9f3a
+ ```
+
+
+
+
+
+## Ship updates
+
+An extension is not finished after the first deploy. The outer loop from the diagram is the normal life of the project: change the code, release a new image tag, deploy it to the same extension.
+
+
+
+1. **Change the code and pass the local loop again** (build → run → test).
+
+2. **Build and push a new tag.**
+
+ ```bash
+ tb extension build --image-tag 1.1.0
+ tb extension push --image-name myorg/user-profile --image-tag 1.1.0
+ ```
+
+ When `--image-tag` is set, `push` also updates `:latest` in the registry to the same build. Pass `--no-latest` to skip that — for example, for a release candidate that should not become the new `latest`.
+
+3. **Deploy the new tag to the same extension.**
+
+ ```bash
+ tb extension deploy --image-name myorg/user-profile --image-tag 1.1.0
+ ```
+
+ The environment variables and the registry credentials are kept from the current deployment, so you do not repeat the `--env` flags — only the new tag is needed. The running extension is not stopped. Its state shows **Updating** while the new version rolls out; replicas that are already ready keep serving traffic. The slug and the URL never change, so dashboards and rule chains keep working.
+
+
+
+4. **Watch the rollout.**
+
+ ```bash
+ tb extension deployments status user-profile-9f3a
+ ```
+
+
+
+If the new version fails to start, the extension moves to **Failed** and the last error is shown in the status. The entity and the slug survive — fix the code and deploy again. To roll back fast, deploy the previous tag:
+
+```bash
+tb extension deploy --image-name myorg/user-profile --image-tag 1.0.0
+```
+
+
+
+## Next steps
+
+- Deploy with the CLI — every flag of `tb extension deploy`, resource sizes, and the `deployments` commands.
+- Complete example: User Profiles — the finished extension: run it, read its code, and change it into your own.
+- Architecture — routing, authentication, extension states, and the three ways to run an extension.
diff --git a/src/content/_includes/docs/reference/extensions/overview.mdx b/src/content/_includes/docs/reference/extensions/overview.mdx
new file mode 100644
index 0000000000..5e7801d25c
--- /dev/null
+++ b/src/content/_includes/docs/reference/extensions/overview.mdx
@@ -0,0 +1,104 @@
+import { Aside } from '@astrojs/starlight/components';
+import DocLink from '@components/DocLink.astro';
+import ImageGallery from '@components/ImageGallery.astro';
+import Banner from '~/components/Banner.astro';
+import { Products } from '~/models/site.models';
+import ShowFor from '@components/ShowFor.astro';
+
+Extensions work only with ThingsBoard Professional and ThingsBoard Cloud version **4.4.0** or later.
+
+{props.product !== Products.CE && (
+ Available from ThingsBoard PE/Cloud 4.4.0 and later.
+)}
+
+A **ThingsBoard extension** is a small service that adds your own REST API to a tenant. You write it in **Java** or **Python**, package it as a Docker image, and ThingsBoard runs it next to the platform.
+
+Clients never call your container directly. They call ThingsBoard at a fixed path:
+
+```text
+{BASE_URL}/api/extension/route/{slug}/…
+```
+
+ThingsBoard checks the caller first and only then forwards the request to your code. A dashboard widget, a rule chain, or an outside script reaches your service with the same login your users already have.
+
+## When you need one
+
+Use an extension when the built-in features are not enough — for example:
+
+- **Rule chain callbacks** — react to device telemetry, entity changes, or alarms from a rule chain.
+- **Dashboard widget backends** — run custom logic when a user clicks a button on a dashboard.
+- **Scheduled jobs** — run a task on a timer that reads or writes ThingsBoard data.
+- **Custom integrations** — talk to a third-party system and expose the result as an API.
+- **Platform management** — bundle several ThingsBoard API calls into one business operation, such as giving a new user a whole set of access rights at once. The User Profiles example does exactly this.
+
+## When you do not need one
+
+An extension is a service that you write, build, and keep up to date. Simpler tools already solve many tasks:
+
+- **A rule node.** Filtering, enrichment, and message transformation belong in a rule chain. Start there.
+- **A REST API call node.** When the logic already runs in another system, call that system from the rule chain.
+- **A widget.** Small logic that only changes what a user sees can stay in the dashboard widget.
+- **The platform REST API.** A script on your own server can already read and write ThingsBoard data through the REST API.
+
+Pick an extension when you need your own endpoint **inside** ThingsBoard, your own libraries, or code that must run close to the platform.
+
+## What you get
+
+- **A URL inside ThingsBoard.** Your service is served from the same origin as the UI and the REST API, so dashboards and rule chains can call it with a relative URL.
+- **Authentication for free on every incoming request.** When a caller — a dashboard widget, a rule chain, an outside script — reaches your endpoint, ThingsBoard has already checked the credential, and your code calls ThingsBoard back with that same caller's credential. No admin password lives inside the extension. Work with no caller behind it, such as a scheduled job, is the exception: it needs its own service credential, set once as an environment variable. See Authentication.
+- **A managed container.** ThingsBoard pulls the image, starts the container, watches it, and restarts it if it fails. You pick the number of replicas and a resource size.
+- **Normal tooling.** It is an ordinary web service: your own dependencies, your own tests, your own release tags. Nothing has to be written in a ThingsBoard-specific way.
+
+## What it looks like
+
+Every extension is a tenant entity, like a device or an asset. You find them in the ThingsBoard UI under **Data processing → Extensions**:
+
+
+
+Each row shows the route, the state, the image, and how many replicas are ready. Open a row to see the container settings, read the logs, or stop and start the service. The **Available resources** line under the table is your tenant quota: how much you already use, and how many extensions of each size you can still deploy.
+
+
+ The quota comes from the tenant profile: a system administrator sets the number of extensions and the total CPU and memory. A tenant cannot change them.
+
+
+ The quota comes from your plan. A tenant cannot change it.
+
+
+## How it works in short
+
+1. **You build an image.** `tb extension new` creates a ready-to-run Java or Python project. You add your endpoints to it, and `tb extension build` turns the project into a Docker image.
+2. **You push and deploy it.** The image goes to a container registry. One `tb extension deploy` command creates the extension entity and asks ThingsBoard to run that image. The CLI is a thin wrapper over the platform's `/api/extension` endpoints, so a CI job can do the same with plain REST calls — they are part of the ThingsBoard REST API.
+3. **ThingsBoard runs the container.** It pulls the image, starts the replicas you asked for, watches them, and restarts them after a failure. You never manage the host.
+4. **Clients call it through ThingsBoard.** The router checks the caller, removes the route prefix, and passes the request to your service. Your code calls the platform back with the caller's own credential.
+
+The Architecture page shows this flow step by step, with the three authentication modes.
+
+## Where extensions run
+
+The same image runs in three places:
+
+- **Public cloud — ThingsBoard Cloud.** Managed for you. Extension containers run in their own cluster, separate from the platform cluster.
+- **Private cloud — your own ThingsBoard PE.** Managed the same way, on your infrastructure. A system administrator picks the substrate: your Kubernetes cluster, in a separate zone next to ThingsBoard, or a single Docker host.
+- **Self-hosted.** You run the container yourself with Docker Compose and route the traffic to it.
+
+The Architecture page compares the three modes in detail.
+
+## What you need to start
+
+- **ThingsBoard PE or Cloud, version 4.4.0 or later.** On ThingsBoard Cloud the feature is already on. On a PE installation, a system administrator turns it on first with the `TB_EXTENSIONS_MODE` setting.
+- **The ThingsBoard CLI and Docker.** The CLI creates the project, builds the image, and deploys it.
+- **A container registry** that ThingsBoard can pull from — Docker Hub, GitHub Container Registry, or a private one.
+- **An API key for your tenant**, so the CLI can talk to ThingsBoard.
+
+Your service only has to follow one rule: listen on port **8090**. The endpoints live at the service root, and the router adds the public `/api/extension/route/{slug}` prefix in front of them. The Java and Python starters do this already.
+
+
+
+## Next steps
+
+- Quickstart — deploy a complete, working extension with one command, watch it run, and read its code.
+- Architecture — routing, authentication, and the three ways to run an extension.
+- Development workflow — the full development loop: plan, code, test, deploy, and ship updates.
+- Deploy with the CLI — the `tb extension deploy` command reference.
diff --git a/src/content/_includes/docs/reference/extensions/quickstart.mdx b/src/content/_includes/docs/reference/extensions/quickstart.mdx
new file mode 100644
index 0000000000..cc954800f5
--- /dev/null
+++ b/src/content/_includes/docs/reference/extensions/quickstart.mdx
@@ -0,0 +1,300 @@
+import { Aside, Steps } from '@astrojs/starlight/components';
+import Tabs from '@components/Tabs.astro';
+import TabItem from '@components/TabItem.astro';
+import DocLink from '@components/DocLink.astro';
+import ImageGallery from '@components/ImageGallery.astro';
+import HostCode from '~/components/HostCode.astro';
+import Banner from '~/components/Banner.astro';
+import { Products } from '~/models/site.models';
+
+Extensions work only with ThingsBoard Professional and ThingsBoard Cloud version **4.4.0** or later.
+
+{props.product !== Products.CE && (
+ Available from ThingsBoard PE/Cloud 4.4.0 and later.
+)}
+
+The fastest way to understand extensions is to watch one run. This page deploys **User Profiles** — a complete, working extension that ships with the ThingsBoard CLI — with a single command. It takes about five minutes.
+
+You write no code here. You do not need Docker or a container registry: the demo deploys a prebuilt public image, and ThingsBoard pulls it. What you get is a real extension with its own REST API, its own tests, and its own dashboard, running inside your ThingsBoard.
+
+New here? Read the Extensions overview first to see how the pieces fit together.
+
+## The feature you are about to deploy
+
+The extension adds **reusable access templates**. A profile — "Manager", "Technician", "Auditor" — names a whole set of rights. One call gives a user that set: a user group, shared dashboards and devices, roles, and a default start page. One call takes it all back.
+
+ThingsBoard PE already has every RBAC piece, but no template that ties them together. Onboarding one user by hand takes about seven screens. The extension replaces those seven screens with one call.
+
+The same feature ships twice — once in **Java** (Spring Boot) and once in **Python** (FastAPI). Both have the same endpoints, the same behavior, and the same dashboard.
+
+## What you need
+
+| | Needed for |
+|---|---|
+| **ThingsBoard CLI** (`tb`) | everything — install with `uv tool install thingsboard-cli` |
+| **A ThingsBoard PE or Cloud instance** | the target of the deploy — version **4.4.0** or later, with extensions enabled |
+| **Docker** | only if you build the image yourself (`--build`) |
+| **A container registry** | only if you build the image yourself (`--build`) |
+
+On a self-hosted PE, a system administrator turns the feature on first with `TB_EXTENSIONS_MODE`. While it is `disabled`, the `/api/extension` endpoints do not exist and the CLI reports that the deployment API is not available. ThingsBoard Cloud has it enabled already.
+
+## Run it
+
+
+
+1. **Point a CLI profile at your instance.**
+
+ \ntb config use mytb`} />
+
+ An API key for a tenant administrator is enough.
+
+2. **Run the demo.**
+
+ ```bash
+ tb init demo
+ ```
+
+ It runs the Java example by default; add `--lang python` for the Python one. Before the first server change it shows a confirmation panel with the target instance and everything it will create. The demo never touches entities it did not create: when a seed name already exists on the instance, `init` stops with the list of collisions (re-run with `--force-adopt` to reuse them). The demo users are activated with a random password, shown once in the final summary; non-interactive runs pass `--demo-password` (or `TB_DEMO_PASSWORD`) and `-y`.
+
+3. **Open what it printed.** The final panel gives you the dashboard URL, the Swagger URL, the extension slug, the project directory, and the two demo users you can log in as — note their password from the summary table, it is shown only there.
+
+
+
+
+
+### What you see
+
+```text
+==> Create the project and scaffold the java extension in ./user-profiles-demo/solutions/user-profiles-demo
+OK Project ready
+
+==> Lay the java user-profiles example over the scaffold
+OK Project ready
+
+==> Check the target ThingsBoard instance
+OK ThingsBoard reachable at https://thingsboard.cloud; extensions API present
+
+╭─ tb init demo ────────────────────────────────────────────────╮
+│ target: https://thingsboard.cloud │
+│ creates: 3 roles, 3 dashboards, 2 entity groups, 2 devices, │
+│ 2 users │
+│ deploys: extension image public.ecr.aws/…/…:java-4.4.0 │
+│ users: john.technician@…, mary.manager@… (TENANT_ADMIN) │
+│ password: random, shown at the end │
+╰───────────────────────────────────────────────────────────────╯
+Proceed? [y/N]: y
+
+==> Use the prebuilt public image (ThingsBoard pulls it, nothing to download)
+
+==> Seed demo data (roles, dashboards, groups, devices, users)
+OK Created: role Dashboard Reader, role Device Operator, role Read Only Tenant,
+dashboard Field Operations, dashboard Plant Overview, dashboard group Ops
+Dashboards, device Plant Pump 1, device Plant Pump 2, device group Plant Devices,
+user john.technician@example.com, user mary.manager@example.com
+
+==> Deploy the extension
+OK Deployed 'user-profiles-demo' → user-profiles-demo-9f3a (1/1 replicas ready)
+
+==> Load the six profile documents through the extension gateway
+
+==> Deploy the demo dashboard
+OK Dashboard deployed
+```
+
+### What the command actually did
+
+| Step | Result |
+|---|---|
+| 1. Unpack | a complete `tb` project in `./user-profiles-demo`, with the demo in `solutions/user-profiles-demo/` — source, tests, Dockerfile, dashboard |
+| 2. Preflight | checks that the instance answers and really exposes the extensions API |
+| 3. Image | uses the prebuilt public image (or builds and pushes yours with `--build`) |
+| 4. Seed | 3 roles, 2 dashboards, 1 dashboard group, 2 devices, 1 device group, 2 tenant users — every name the profiles point at. The demo users are taken out of `Tenant Administrators`, so they start with nothing |
+| 5. Deploy | the **extension entity** plus a running container; waits for `ACTIVE` |
+| 6. Profiles | `POST`s the six profile documents **through the gateway**, which also proves routing and auth work |
+| 7. Dashboard | imports the demo dashboard, with the extension name filled in |
+
+
+
+## See the deployment in ThingsBoard
+
+Open **Data processing → Extensions**. The extension is a normal tenant entity with its own route, state, image, and replicas:
+
+
+
+Click a row for the details — the state, the ready replicas, the time of the last status check, and the container configuration:
+
+
+
+**Show logs** opens the container log — the same log as `tb extension deployments logs`. Here it shows requests arriving through the router and the responses that went back:
+
+
+
+The same from the terminal:
+
+```bash
+tb extension deployments list # every extension of the tenant, with its slug
+tb extension deployments status $SLUG
+tb extension deployments logs $SLUG -n 100
+```
+
+`status` and `logs` take the **slug** (or the extension UUID), not the name — `deployments list` shows both if you no longer have the panel. `status` prints the state of the extension — `Provisioning`, `Active`, `Updating`, `Degraded`, `Stopped`, or `Failed`. The Architecture page explains what each state means. You can also **stop**, **start**, and **restart** an extension at any time — see Inspecting and controlling a deployment.
+
+## The two-minute tour
+
+Open the demo dashboard. On the left, the **Tenant Admin User Profiles** card loads the catalog straight from the extension; the small **?** in its header opens a short guide and the full API list. On the right, the **Tenant Admin Users** table lists the tenant users with a **Display name**, an **Email** and a **Profile** column. Each row offers only the actions it can do: **+** to assign, the pencil to change, and the person-minus to unassign.
+
+
+
+
+
+1. **Assign.** Click **Assign profile** on a user's row and pick a profile.
+
+
+
+ The widget calls:
+
+ ```http
+ POST /api/extension/route/{slug}/userProfile/name/Technician/assign/
+ ```
+
+ The widget puts the real slug in that URL — it looked it up by extension name when the dashboard loaded.
+
+ ```json
+ {
+ "userId": "86199120-885b-11f1-babd-4b4cab135c04",
+ "profile": "Technician",
+ "userGroupId": "8760f970-8904-11f1-ae2f-4b4cab135c04",
+ "defaultDashboardId": "85c72b60-885b-11f1-babd-4b4cab135c04",
+ "createdPermissionIds": ["877e6c80-8904-…", "8781c7e0-8904-…"],
+ "alreadyApplied": false
+ }
+ ```
+
+ The **Profile** column fills in at once.
+
+2. **Look at what it created.** Go to **Customers & users → Users → Groups**. A `Technicians` user group appeared — a normal ThingsBoard group, created by the extension a moment ago:
+
+
+
+ Open it: the user is inside.
+
+
+
+ Its roles carry the permissions the profile describes: the `Dashboard Reader` role on `Ops Dashboards`, and the same role on `Plant Devices`. Nothing here is special to the extension — you could have built it by hand in seven screens.
+
+3. **Log in as that user** (`john.technician@example.com`, password from the demo-users table in the summary) in a private window. Before the assign, this account saw an empty platform. Now the menu is short, the start page is **Field Operations**, and under **Dashboards** there is exactly one shared group — `Ops Dashboards`, the one the Technician profile shares:
+
+
+
+4. **Assign the same profile to a second user.** The response has an **empty** `createdPermissionIds`: the group and its permissions already exist and are reused, not duplicated.
+
+5. **Unassign.** Click **Unassign** and confirm. The widget sends `DELETE` to the same path it assigned with — the HTTP method is the verb, exactly as in `DELETE /api/alarm/{alarmId}/assign`. That user's next login shows an empty platform again. The `Technicians` group and its permissions stay if anyone else still needs them — only this user's membership and default dashboard were removed. Add `?cleanup=true` and the group is deleted too, but only when this extension created it and it is now empty.
+
+6. **The owner rule.** Profiles in this example are **tenant-only**. The user group always belongs to the tenant, and ThingsBoard refuses a customer user inside a tenant-owned group, so assign checks the authority first and answers **400** — `user … is a customer user; profiles apply to tenant users only` — before it writes anything. That is also why the users table on the dashboard lists tenant users only.
+
+
+
+## Try the API yourself
+
+The extension serves its own OpenAPI page on your ThingsBoard origin — `/swagger-ui/index.html` for Java, `/docs` for Python:
+
+
+
+The docs page is public on purpose; the endpoints are not:
+
+ the gateway rejects the call
+curl -s -o /dev/null -w '%{http_code}\\n' \\
+ "{BASE_URL}/api/extension/route/$SLUG/userProfiles?pageSize=100&page=0" # 401
+
+# a valid tenant credential -> allowed
+curl -s -H "X-Authorization: ApiKey $KEY" \\
+ "{BASE_URL}/api/extension/route/$SLUG/userProfiles?pageSize=100&page=0" # 200, a PageData of six`} />
+
+## Clean up
+
+Undeploy the extension — this removes the container and the extension entity, nothing else:
+
+```bash
+tb extension undeploy --solution user-profiles-demo
+```
+
+Everything the demo seeded is an ordinary ThingsBoard entity, so delete what you no longer want in the UI or with the CLI: the two demo users, the `Field Operations` and `Plant Overview` dashboards plus the demo dashboard, the `Ops Dashboards` and `Plant Devices` groups, the two plant devices, the three roles, the user groups the profiles created, and the `user-profiles` asset that stores the profile documents.
+
+The project files stay on your machine. Delete the directory when you are done, or drop only the solution with `tb solution delete user-profiles-demo --force`.
+
+
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| "ThingsBoard … does not expose the extensions API" | the instance is CE, older than 4.4.0, or `extensions.mode` is `disabled` | use PE/Cloud 4.4.0 or later and ask the system administrator to set `TB_EXTENSIONS_MODE` |
+| "These entities already exist on the target instance" | the tenant already uses one of the seed names (a role, dashboard, device or user) | rename/remove the listed entities, or re-run with `--force-adopt` to reuse them (adopted entities are then modified by the demo) |
+| `Extension with such name already exists!` | this tenant already has an extension named after the solution, `user-profiles-demo` | undeploy it, or install the demo into another solution: `tb init demo --dir ` |
+| `Extension slug 'user-profiles-demo' is already in use!` | on an installation without the random suffix, the slug is the base itself, and the local deployment state was reset while the extension is still deployed | tear the old one down first: `tb extension undeploy ` |
+| `Extension limit reached for this tenant (max N)!` | the tenant already has as many extensions as the tenant profile allows; stopped ones still count | undeploy an extension you no longer need, or ask the system administrator to raise the limit |
+| `Extensions CPU limit reached for this tenant: …` or `Extensions memory limit reached for this tenant: …` | the size multiplied by the replicas does not fit the free CPU or memory of the tenant | deploy a smaller `--size`, use fewer `--replicas`, or stop another extension — a stopped extension frees its CPU and memory |
+| "No profile could be loaded through the gateway" | the origin you use does not route the extension path to the extension | pass `--gw `, then check `tb extension deployments logs ` |
+| The extension is `ACTIVE`, but the first calls fail | the container is running while the app is still starting | the demo waits for the gateway before it loads the profiles; in your own scripts, retry the first call |
+| `tb extension deploy` says "No such command" | an older CLI is installed | reinstall it: `uv tool install --force thingsboard-cli` |
+| Extension containers keep disappearing | two ThingsBoard instances in `docker-compose` extensions mode share one Docker host | run only one such stack per host |
+| The Swagger link with `/docs` gives 404 | that is the Python path | Java serves `/swagger-ui/index.html` |
+
+## Complete example: User Profiles
+
+What you deployed is a complete example, not a stub. The code ships with the CLI in two languages, and it is yours to read and change.
+
+| Example | Language | Source |
+|---|---|---|
+| `user-profiles-java` | Java (Spring Boot) | [thingsboard-cli/examples/user-profiles-java](https://github.com/thingsboard/thingsboard-cli/tree/master/examples/user-profiles-java) |
+| `user-profiles-python` | Python (FastAPI) | [thingsboard-cli/examples/user-profiles-python](https://github.com/thingsboard/thingsboard-cli/tree/master/examples/user-profiles-python) |
+
+**The scenario.** A profile is a reusable access template. One `assign` call replaces seven manual screens: it creates the user group if it is missing, adds the user, creates the group permissions, shares the dashboard and device groups, and sets the start page. It records what it created, so `unassign` takes exactly that back. Every reference inside a profile is a **name**, never a UUID, so the same profile document works on dev, test, and prod. Everything the extension creates is an ordinary ThingsBoard entity and stays editable in the UI.
+
+**Run it.** `tb init demo` unpacks the project into `./user-profiles-demo/solutions/user-profiles-demo/` — source, tests, `Dockerfile`, dashboard, and the README. Add `--lang python` for the Python version. The tests run without a ThingsBoard: an in-memory stand-in answers the platform REST API.
+
+
+
+ ```bash
+ cd extension && mvn -pl extension test
+ ```
+
+
+ ```bash
+ cd extension && uv run pytest -q
+ ```
+
+
+
+**Change it and ship your own version.** The unpacked project is yours. Edit the code, then follow the normal release loop — build, run, push, deploy — in the Development workflow. Run those commands from the solution directory, or add `--solution user-profiles-demo` from the project root.
+
+**Import the dashboard.** `tb init demo` does this for you. To import it by hand, replace the two placeholders first — the extension name and the dashboard title:
+
+```bash
+sed -e "s|__EXTENSION_NAME__|user-profiles-demo|g" \
+ -e "s|__DASHBOARD_TITLE__|Tenant Admin Users — Access Management|g" \
+ solutions/user-profiles-demo/dashboards/user-profiles.json > /tmp/user-profiles-dashboard.json
+tb dashboard save --json /tmp/user-profiles-dashboard.json --remote
+```
+
+The dashboard carries the extension **name**, not its slug, and resolves the slug when it loads. Its URLs are relative and the widgets send the caller's own session token, so the exported file imports on any instance without editing.
+
+The rest — the seven endpoints, the profile document, the error codes, the rollback record, the widget JavaScript, and the tests — is in the example's own `README.md`, next to the code.
+
+## What to read next
+
+- Architecture — how the request reached the container: routing, authentication, states, and where extensions run.
+- Development workflow — build your own extension: plan, generate the code, test, deploy, and ship updates.
+- Deploy with the CLI — every flag of `tb extension deploy`, resource sizes, and the `deployments` commands.
diff --git a/src/content/_includes/docs/user-guide/cli-extensions.mdx b/src/content/_includes/docs/user-guide/cli-extensions.mdx
new file mode 100644
index 0000000000..fa09c75579
--- /dev/null
+++ b/src/content/_includes/docs/user-guide/cli-extensions.mdx
@@ -0,0 +1,255 @@
+import { Aside } from '@astrojs/starlight/components';
+import DocLink from '@components/DocLink.astro';
+import ImageGallery from '@components/ImageGallery.astro';
+import Banner from '~/components/Banner.astro';
+import { Products } from '~/models/site.models';
+import ShowFor from '@components/ShowFor.astro';
+
+Extensions work only with ThingsBoard Professional and ThingsBoard Cloud version **4.4.0** or later.
+
+{props.product !== Products.CE && (
+ Available from ThingsBoard PE/Cloud 4.4.0 and later.
+)}
+
+The ThingsBoard CLI is the easiest way to run an extension inside ThingsBoard Cloud or PE. This page is the reference for the `tb extension deploy` command and the `tb extension deployments` control commands. For the walkthrough that leads up to a deploy, see the Development workflow.
+
+
+
+## Before you deploy
+
+`deploy` only provisions an image the registry already holds. Push it first:
+
+```bash
+tb extension push --image-name myorg/acme-billing
+```
+
+When you push a version tag (`--image-tag 1.1.0`), the CLI also pushes the same build as `:latest`, replacing the registry's current `latest`. Pass `--no-latest` to skip that — for example, for release candidates.
+
+Authentication uses your active profile. Override it per command with `--profile `, or with `--url` and `--api-key`. The profile also decides **where** the extension runs — ThingsBoard Cloud or your own PE installation (see Where extensions run).
+
+The registry must be reachable from the ThingsBoard instance that pulls the image: the public internet for ThingsBoard Cloud, or your own network for a private PE installation. For a private registry, ThingsBoard needs pull credentials — pass them with the registry flags of `deploy` (see [Private registry](#private-registry)).
+
+## Root and solution extensions
+
+A project can hold more than one extension:
+
+- one at the project root: `extension/`
+- one per solution: `solutions//extension/`
+
+Every `tb extension` command picks its target the same way:
+
+1. `--solution ` targets that solution's extension.
+2. Without the flag, a shell inside `solutions//` targets that solution. The command prints the scope it picked.
+3. Anywhere else, the project-root extension is used.
+
+Each extension keeps its own block in `thingsboard.json` (see [Deployment state in the project](#deployment-state-in-the-project)), so names, images, and deployment state never mix. For example, deploy a solution's extension with:
+
+```bash
+tb extension deploy --solution smart-building --image-name myorg/smart-building-ext
+```
+
+A solution directory has to exist before you can put an extension in it:
+
+```bash
+tb solution new smart-building
+tb extension new --solution smart-building --lang python
+```
+
+Prefer the flag over changing directory. It names the extension you mean, whatever your shell is. If the solution is not there, the CLI stops and asks you to run `tb solution new ` first.
+
+Two limits apply once a project holds several extensions:
+
+- **Locally**, only one of them runs at a time, because they share the same port. Stop the other one first: `tb extension stop --solution `.
+- **Deployed**, each one takes a part of the tenant quota — extension count, CPU, and memory. The Extensions page shows what is left (see What it looks like).
+
+## Commands at a glance
+
+| Command | What it does |
+|---------|--------------|
+| `tb extension deploy` | Create or update the deployment from a pushed image |
+| `tb extension undeploy [ref]` | Tear down the deployment and its entity |
+| `tb extension sizes` | List the available resource sizes |
+| `tb extension deployments list` | List this tenant's deployed extensions |
+| `tb extension deployments status ` | Show live state, replicas, and URL |
+| `tb extension deployments logs ` | Tail the container logs |
+| `tb extension deployments restart ` | Roll out a restart |
+| `tb extension deployments stop ` | Scale to zero (keep the entity) |
+| `tb extension deployments start ` | Resume to the desired replicas |
+
+`` is the extension **slug** or **UUID**. When a flag is omitted, the CLI reads its value (name, image, slug, size) from the project's `thingsboard.json`.
+
+## `tb extension deploy`
+
+Create or update the deployment. The CLI shows a confirmation panel, sends the request, and by default waits until the extension is `ACTIVE`.
+
+```bash
+tb extension deploy --image-name myorg/acme-billing --image-tag 1.0 --replicas 2 --size medium
+```
+
+```text
+╭─ tb extension deploy ─────────────────────────────╮
+│ Deploy 'acme-billing' │
+│ image: myorg/acme-billing:1.0 │
+│ replicas: 2 port: 8090 │
+│ size: medium │
+│ target: https://thingsboard.cloud │
+╰────────────────────────────────────────────────────╯
+Proceed? [y/N]: y
+OK Deployed 'acme-billing' → acme-billing-9f3a (2/2 replicas ready)
+ URL: https://thingsboard.cloud/api/extension/route/acme-billing-9f3a
+```
+
+### Flags
+
+| Flag | Default | Description |
+|------|---------|-------------|
+| `--image-name` | saved image | Registry image repository, **without a tag** |
+| `--image-tag` | `latest` | Image tag |
+| `--name` | saved extension name | Human-readable extension name (from `thingsboard.json`, set by `tb extension new` or `rename`) |
+| `--slug-base` | from name | Base for the generated slug |
+| `--replicas` | current deployment → saved value → `1` | Number of replicas (1–20) |
+| `--port` | current deployment → saved value → `8090` | Port the container listens on |
+| `--env KEY=VALUE` | — | Environment variable; repeat for more than one. Names starting with `TB_EXT_`, and the key `THINGSBOARD_URL`, are reserved |
+| `--size` | saved size → `small` | Resource size — run `tb extension sizes` to list the options |
+| `--registry` | from image | Private registry host. When omitted, ThingsBoard derives it from the image reference |
+| `--registry-user` | — | Username (or robot account) for a private registry — see [Private registry](#private-registry) |
+| `--registry-password-secret` | — | Name of a ThingsBoard Secret that holds the registry password — see [Private registry](#private-registry) |
+| `--clear-registry-auth` | — | On an update, remove the stored registry credentials instead of keeping them |
+| `--clear-env` | — | On an update, remove all environment variables instead of keeping them |
+| `--wait` / `--no-wait` | `--wait` | Wait until the deployment settles (see [Wait outcomes](#wait-outcomes)), or return at once |
+| `--timeout` | `180` | Wait timeout, in seconds |
+| `--solution` | auto | Target a solution's extension instead of the project-root one — see [Root and solution extensions](#root-and-solution-extensions) |
+| `-y` / `--yes` | — | Skip the confirmation prompt |
+
+
+
+The `size:` line is always shown — it takes its value from `--size`, the saved deployment, or the default. Optional flags you set add extra rows. For example, `--env LOG_LEVEL=debug --size medium` shows:
+
+```text
+│ size: medium │
+│ env: LOG_LEVEL=debug │
+```
+
+On an update, when you do not pass `--env` or the registry flags, the CLI keeps the current values from the server. The panel then shows `env: (kept from current deployment)` or `registry-auth: (kept from current deployment)`.
+
+With `--no-wait`, the command returns immediately and the state updates in the background:
+
+```text
+OK Provisioned 'acme-billing' → acme-billing-9f3a (state will converge; use deployments status)
+```
+
+### Wait outcomes
+
+With `--wait` (the default), the command polls the extension state and reacts to each state differently. Only `ACTIVE` counts as success; every other outcome exits with a non-zero code and a message that names the real state:
+
+| Final state | What the CLI does |
+|-------------|-------------------|
+| `ACTIVE` | Success: `Deployed '…' → (n/n replicas ready)`. |
+| `FAILED` | Stops at once and prints the `lastError` (for example, an image pull failure). |
+| `STOPPED`, `DELETING` | Stops at once — someone stopped or deleted the extension while the CLI was waiting (for example, from the UI). The message names the state instead of reporting a timeout. |
+| `DEGRADED` at the timeout | Keeps polling while `DEGRADED` — the extension may still recover. If `--timeout` runs out in this state, the CLI reports the ready/desired replicas and the `lastError`. |
+| `PROVISIONING` / `UPDATING` at the timeout | Reports that the extension did not become `ACTIVE` in time. It may still be converging — check `tb extension deployments status `. |
+
+### Private registry
+
+ThingsBoard pulls the image on the server side, so it needs its own pull credentials — your local `docker login` does not help here. The CLI never sends the password itself. Instead, it sends a reference to a ThingsBoard Secret:
+
+1. Create a Secret with the registry password under your tenant — use the **Secrets** page in the ThingsBoard UI, or `POST /api/secret`.
+2. Deploy with the username and the secret name:
+
+```bash
+tb extension deploy --registry-user robot-acme --registry-password-secret acme-registry
+```
+
+`--registry-user` and `--registry-password-secret` are required together. `--registry` is optional: when omitted, ThingsBoard takes the registry host from the image reference. Under the hood, the CLI fills the `registryAuth` field of the deployment request and sends the password as a `${secret:acme-registry;type:TEXT}` reference. You can also pass a full `${secret:…}` reference as the flag value — the CLI keeps it as is.
+
+You set the registry flags once. On later updates, the CLI keeps the credentials from the current deployment — you do not repeat the flags. Pass new values to replace them, or `--clear-registry-auth` to remove them.
+
+## Resource sizes
+
+Run `tb extension sizes` to list the resource tiers and the CPU and memory each one maps to. There are six: `xsmall`, `small`, `medium`, `large`, `xlarge`, and `xxlarge`. Pass the chosen tier to `deploy` with `--size`. Start small and move up only if the extension needs more.
+
+Without `--size`, a new deployment gets `small`. Keep it there for a Java extension: `xsmall` gives the container 128 MB, which is not enough for a JVM, so the process is killed before Spring finishes starting.
+
+
+
+## `tb extension undeploy [ref]`
+
+Delete the deployment and its entity. With no `ref`, the CLI uses the saved deployment from `thingsboard.json`.
+
+```bash
+tb extension undeploy acme-billing-9f3a
+```
+
+## Inspecting and controlling a deployment
+
+```bash
+tb extension deployments list # all extensions in the tenant
+tb extension deployments status acme-billing-9f3a # live state, replicas, URL
+tb extension deployments logs acme-billing-9f3a -n 200 # tail the last 200 log lines
+tb extension deployments restart acme-billing-9f3a # roll out a restart
+tb extension deployments stop acme-billing-9f3a # scale to zero, keep the entity
+tb extension deployments start acme-billing-9f3a # resume to the desired replicas
+```
+
+`list` prints one row per extension: name, slug, state, resource size, image, and the docs path. The docs path (Swagger UI for Java, `/docs` for Python) is filled only for extensions deployed from the current project — for those, the CLI knows the language from `thingsboard.json`. For extensions deployed elsewhere the cell stays empty instead of guessing a link that may not exist.
+
+`status` shows the live state, ready and desired replicas, and the public URL, plus a **Docs** row when the extension was deployed from the current project. It adds a **Last error** row only when the extension reports one.
+
+The same information is in the UI, under **Data processing → Extensions**. `logs` and the **Show logs** button read the same container log:
+
+
+
+## Deployment state in the project
+
+After a successful deploy, the CLI saves the deployment details in the project's `thingsboard.json`:
+
+```json
+{
+ "extension": {
+ "name": "acme-billing",
+ "language": "java",
+ "deployment": {
+ "id": "…", "slug": "acme-billing-9f3a",
+ "lastImage": "myorg/acme-billing", "lastTag": "1.0", "size": "medium"
+ }
+ }
+}
+```
+
+A solution's extension saves the same block under its own solution instead — `solutions..extension.deployment` — so the root extension and every solution extension track their deployments separately.
+
+Because of this, a later `tb extension deploy` with no flags **updates** the same extension using the saved image, slug, and size — you do not have to repeat them. The replica count, the port, the environment variables, and the private-registry credentials are kept too: the CLI reads them from the current deployment on the server, so a scale you made in the UI survives the next CLI deploy. Pass the matching flag to replace any of them, or `--clear-env` / `--clear-registry-auth` to remove them. Note that `--env` replaces the whole set — repeat every variable you want to keep. Only the image tag is **not** reused: `lastTag` is informational, and without `--image-tag` the CLI deploys `latest`. So pass `--image-tag` on every update.
+
+## Deploy from CI
+
+Environment variables let you deploy without an interactive profile — useful in a pipeline:
+
+```bash
+export TB_URL=https://thingsboard.cloud
+export TB_API_KEY=tb_XXXX
+tb extension deploy --image-name myorg/acme-billing --image-tag "$CI_COMMIT_TAG" -y --no-wait
+```
+
+`TB_URL` sets the instance, `TB_API_KEY` supplies the key, `-y` skips the prompt, and `--no-wait` returns at once.
+
+
+
+## Next steps
+
+- Development workflow — the full build-and-deploy walkthrough for your own extension.
diff --git a/src/content/_includes/docs/user-guide/cli-solutions.mdx b/src/content/_includes/docs/user-guide/cli-solutions.mdx
index 7368d76994..afecdbde93 100644
--- a/src/content/_includes/docs/user-guide/cli-solutions.mdx
+++ b/src/content/_includes/docs/user-guide/cli-solutions.mdx
@@ -238,3 +238,4 @@ This walkthrough is only a small showcase — the same describe, review, deploy
After a push, the CLI may ask how it went — share your experience any time with `tb feedback`.
+
diff --git a/src/content/_includes/docs/user-guide/cli.mdx b/src/content/_includes/docs/user-guide/cli.mdx
index ee60cd42cf..6451260067 100644
--- a/src/content/_includes/docs/user-guide/cli.mdx
+++ b/src/content/_includes/docs/user-guide/cli.mdx
@@ -230,6 +230,8 @@ tb device save --solution smart-building --name Sensor-01 --type "Temperature Se
To save an entity under a solution, add `--solution ` to the command — it works from anywhere in the project.
+A solution can also ship its own backend extension at `solutions//extension/`, next to a project-wide one at the project root. All `tb extension` commands accept the same `--solution` flag — see Root and solution extensions.
+
#### Validate and push
```bash
diff --git a/src/content/_includes/docs/user-guide/tenant-profiles.mdx b/src/content/_includes/docs/user-guide/tenant-profiles.mdx
index a18b31eb66..704dfd295b 100644
--- a/src/content/_includes/docs/user-guide/tenant-profiles.mdx
+++ b/src/content/_includes/docs/user-guide/tenant-profiles.mdx
@@ -1,6 +1,9 @@
import DocLink from '@components/DocLink.astro';
import { Steps, Aside } from '@astrojs/starlight/components';
import { Products } from '~/models/site.models';
+import ShowFor from '@components/ShowFor.astro';
+import ConditionalHeading from '@components/ConditionalHeading.astro';
+import ImageGallery from '@components/ImageGallery.astro';
A **Tenant profile** defines the resource limits, quotas, and operational behavior applied to tenants in ThingsBoard. It provides resource isolation across tenants, allowing System administrators to enforce fair resource usage, maintain predictable performance, and prevent overload in multi-tenant deployments.
@@ -81,6 +84,30 @@ Defines the maximum number of entities a tenant can create:
A value of `0` means unlimited.
+
+
+Extension limits
+
+Defines how many extensions a tenant can create, and how much compute they may use in total:
+
+- **Extensions maximum number**
+- **Extensions total CPU limit (CPU cores)**
+- **Extensions total memory limit (MB)**
+
+A value of `0` means unlimited.
+
+
+
+CPU and memory usage is the sum over all running extensions of the tenant. For each extension, it is the CPU and memory of its resource size multiplied by the number of replicas. Two replicas of a `medium` extension use twice the CPU and memory of one.
+
+An extension in the `STOPPED` state runs no containers, so it frees its CPU and memory. It still counts toward **Extensions maximum number**, because the entity still exists. The CPU and memory check runs again when the extension is started, so a start can fail if other extensions took the free capacity in the meantime.
+
+The count limit is checked when an extension is created. The CPU and memory limits are checked when an extension is created, updated, or started. An update that asks for less CPU and memory than before always passes, even if the limits were lowered below the current usage.
+
+Tenant administrators cannot change these values. They see the current usage in the **Available resources** line under the extensions table, on the **Data processing → Extensions** page.
+
+
+
### API Limits & Usage
Defines high-level API and processing limits:
diff --git a/src/content/docs/docs/paas/eu/reference/extensions/architecture.mdx b/src/content/docs/docs/paas/eu/reference/extensions/architecture.mdx
new file mode 100644
index 0000000000..ad81b708e4
--- /dev/null
+++ b/src/content/docs/docs/paas/eu/reference/extensions/architecture.mdx
@@ -0,0 +1,8 @@
+---
+title: Architecture
+description: "How a ThingsBoard extension works: request routing, authentication, and where the container runs."
+---
+import Architecture from '@includes/docs/reference/extensions/architecture.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/eu/reference/extensions/development-workflow.mdx b/src/content/docs/docs/paas/eu/reference/extensions/development-workflow.mdx
new file mode 100644
index 0000000000..24e34d9a1f
--- /dev/null
+++ b/src/content/docs/docs/paas/eu/reference/extensions/development-workflow.mdx
@@ -0,0 +1,8 @@
+---
+title: Development workflow
+description: "Plan, generate, test, deploy, and update a ThingsBoard extension — the full development loop shown on the User Profiles example."
+---
+import DevelopmentWorkflow from '@includes/docs/reference/extensions/development-workflow.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/eu/reference/extensions/overview.mdx b/src/content/docs/docs/paas/eu/reference/extensions/overview.mdx
new file mode 100644
index 0000000000..52c1a8f0c1
--- /dev/null
+++ b/src/content/docs/docs/paas/eu/reference/extensions/overview.mdx
@@ -0,0 +1,8 @@
+---
+title: Extensions overview
+description: "What ThingsBoard extensions are, when you need one, and what the platform gives you."
+---
+import Overview from '@includes/docs/reference/extensions/overview.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/eu/reference/extensions/quickstart.mdx b/src/content/docs/docs/paas/eu/reference/extensions/quickstart.mdx
new file mode 100644
index 0000000000..7f7375a3a8
--- /dev/null
+++ b/src/content/docs/docs/paas/eu/reference/extensions/quickstart.mdx
@@ -0,0 +1,8 @@
+---
+title: Quickstart
+description: "Deploy a complete, working ThingsBoard extension with one command and see it running in five minutes."
+---
+import Quickstart from '@includes/docs/reference/extensions/quickstart.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/eu/user-guide/cli-extensions.mdx b/src/content/docs/docs/paas/eu/user-guide/cli-extensions.mdx
new file mode 100644
index 0000000000..416ba0b9f7
--- /dev/null
+++ b/src/content/docs/docs/paas/eu/user-guide/cli-extensions.mdx
@@ -0,0 +1,10 @@
+---
+title: Deploy extensions with ThingsBoard CLI
+description: "Reference for the tb extension commands: deploy, resource sizes, private registry, and the deployments controls."
+sidebar:
+ label: Extensions
+---
+import PageComponent from '@includes/docs/user-guide/cli-extensions.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/reference/extensions/architecture.mdx b/src/content/docs/docs/paas/reference/extensions/architecture.mdx
new file mode 100644
index 0000000000..1febdfca42
--- /dev/null
+++ b/src/content/docs/docs/paas/reference/extensions/architecture.mdx
@@ -0,0 +1,8 @@
+---
+title: Architecture
+description: "How a ThingsBoard extension works: request routing, authentication, and where the container runs."
+---
+import Architecture from '@includes/docs/reference/extensions/architecture.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/reference/extensions/development-workflow.mdx b/src/content/docs/docs/paas/reference/extensions/development-workflow.mdx
new file mode 100644
index 0000000000..8b964a9dc3
--- /dev/null
+++ b/src/content/docs/docs/paas/reference/extensions/development-workflow.mdx
@@ -0,0 +1,8 @@
+---
+title: Development workflow
+description: "Plan, generate, test, deploy, and update a ThingsBoard extension — the full development loop shown on the User Profiles example."
+---
+import DevelopmentWorkflow from '@includes/docs/reference/extensions/development-workflow.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/reference/extensions/overview.mdx b/src/content/docs/docs/paas/reference/extensions/overview.mdx
new file mode 100644
index 0000000000..325d43272d
--- /dev/null
+++ b/src/content/docs/docs/paas/reference/extensions/overview.mdx
@@ -0,0 +1,8 @@
+---
+title: Extensions overview
+description: "What ThingsBoard extensions are, when you need one, and what the platform gives you."
+---
+import Overview from '@includes/docs/reference/extensions/overview.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/reference/extensions/quickstart.mdx b/src/content/docs/docs/paas/reference/extensions/quickstart.mdx
new file mode 100644
index 0000000000..1259e29876
--- /dev/null
+++ b/src/content/docs/docs/paas/reference/extensions/quickstart.mdx
@@ -0,0 +1,8 @@
+---
+title: Quickstart
+description: "Deploy a complete, working ThingsBoard extension with one command and see it running in five minutes."
+---
+import Quickstart from '@includes/docs/reference/extensions/quickstart.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/paas/user-guide/cli-extensions.mdx b/src/content/docs/docs/paas/user-guide/cli-extensions.mdx
new file mode 100644
index 0000000000..12f08a01da
--- /dev/null
+++ b/src/content/docs/docs/paas/user-guide/cli-extensions.mdx
@@ -0,0 +1,10 @@
+---
+title: Deploy extensions with ThingsBoard CLI
+description: "Reference for the tb extension commands: deploy, resource sizes, private registry, and the deployments controls."
+sidebar:
+ label: Extensions
+---
+import PageComponent from '@includes/docs/user-guide/cli-extensions.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/pe/reference/extensions/architecture.mdx b/src/content/docs/docs/pe/reference/extensions/architecture.mdx
new file mode 100644
index 0000000000..6af9650949
--- /dev/null
+++ b/src/content/docs/docs/pe/reference/extensions/architecture.mdx
@@ -0,0 +1,8 @@
+---
+title: Architecture
+description: "How a ThingsBoard extension works: request routing, authentication, and where the container runs."
+---
+import Architecture from '@includes/docs/reference/extensions/architecture.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/pe/reference/extensions/development-workflow.mdx b/src/content/docs/docs/pe/reference/extensions/development-workflow.mdx
new file mode 100644
index 0000000000..b9e550a9d2
--- /dev/null
+++ b/src/content/docs/docs/pe/reference/extensions/development-workflow.mdx
@@ -0,0 +1,8 @@
+---
+title: Development workflow
+description: "Plan, generate, test, deploy, and update a ThingsBoard extension — the full development loop shown on the User Profiles example."
+---
+import DevelopmentWorkflow from '@includes/docs/reference/extensions/development-workflow.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/pe/reference/extensions/overview.mdx b/src/content/docs/docs/pe/reference/extensions/overview.mdx
new file mode 100644
index 0000000000..1d8692cb70
--- /dev/null
+++ b/src/content/docs/docs/pe/reference/extensions/overview.mdx
@@ -0,0 +1,8 @@
+---
+title: Extensions overview
+description: "What ThingsBoard extensions are, when you need one, and what the platform gives you."
+---
+import Overview from '@includes/docs/reference/extensions/overview.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/pe/reference/extensions/quickstart.mdx b/src/content/docs/docs/pe/reference/extensions/quickstart.mdx
new file mode 100644
index 0000000000..659c9b2c3b
--- /dev/null
+++ b/src/content/docs/docs/pe/reference/extensions/quickstart.mdx
@@ -0,0 +1,8 @@
+---
+title: Quickstart
+description: "Deploy a complete, working ThingsBoard extension with one command and see it running in five minutes."
+---
+import Quickstart from '@includes/docs/reference/extensions/quickstart.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/pe/user-guide/cli-extensions.mdx b/src/content/docs/docs/pe/user-guide/cli-extensions.mdx
new file mode 100644
index 0000000000..22d9776729
--- /dev/null
+++ b/src/content/docs/docs/pe/user-guide/cli-extensions.mdx
@@ -0,0 +1,10 @@
+---
+title: Deploy extensions with ThingsBoard CLI
+description: "Reference for the tb extension commands: deploy, resource sizes, private registry, and the deployments controls."
+sidebar:
+ label: Extensions
+---
+import PageComponent from '@includes/docs/user-guide/cli-extensions.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/reference/extensions/architecture.mdx b/src/content/docs/docs/reference/extensions/architecture.mdx
new file mode 100644
index 0000000000..c621e328a7
--- /dev/null
+++ b/src/content/docs/docs/reference/extensions/architecture.mdx
@@ -0,0 +1,8 @@
+---
+title: Architecture
+description: "How a ThingsBoard extension works: request routing, authentication, and where the container runs."
+---
+import Architecture from '@includes/docs/reference/extensions/architecture.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/reference/extensions/development-workflow.mdx b/src/content/docs/docs/reference/extensions/development-workflow.mdx
new file mode 100644
index 0000000000..5c2c6f87ab
--- /dev/null
+++ b/src/content/docs/docs/reference/extensions/development-workflow.mdx
@@ -0,0 +1,8 @@
+---
+title: Development workflow
+description: "Plan, generate, test, deploy, and update a ThingsBoard extension — the full development loop shown on the User Profiles example."
+---
+import DevelopmentWorkflow from '@includes/docs/reference/extensions/development-workflow.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/reference/extensions/overview.mdx b/src/content/docs/docs/reference/extensions/overview.mdx
new file mode 100644
index 0000000000..50979da629
--- /dev/null
+++ b/src/content/docs/docs/reference/extensions/overview.mdx
@@ -0,0 +1,8 @@
+---
+title: Extensions overview
+description: "What ThingsBoard extensions are, when you need one, and what the platform gives you."
+---
+import Overview from '@includes/docs/reference/extensions/overview.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/reference/extensions/quickstart.mdx b/src/content/docs/docs/reference/extensions/quickstart.mdx
new file mode 100644
index 0000000000..297af3c9aa
--- /dev/null
+++ b/src/content/docs/docs/reference/extensions/quickstart.mdx
@@ -0,0 +1,8 @@
+---
+title: Quickstart
+description: "Deploy a complete, working ThingsBoard extension with one command and see it running in five minutes."
+---
+import Quickstart from '@includes/docs/reference/extensions/quickstart.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/content/docs/docs/user-guide/cli-extensions.mdx b/src/content/docs/docs/user-guide/cli-extensions.mdx
new file mode 100644
index 0000000000..68995ef1f0
--- /dev/null
+++ b/src/content/docs/docs/user-guide/cli-extensions.mdx
@@ -0,0 +1,10 @@
+---
+title: Deploy extensions with ThingsBoard CLI
+description: "Reference for the tb extension commands: deploy, resource sizes, private registry, and the deployments controls."
+sidebar:
+ label: Extensions
+---
+import PageComponent from '@includes/docs/user-guide/cli-extensions.mdx'
+import { Products } from '~/models/site.models'
+
+
diff --git a/src/data/redirects.ts b/src/data/redirects.ts
index bc0d830742..df017c3537 100644
--- a/src/data/redirects.ts
+++ b/src/data/redirects.ts
@@ -233,6 +233,14 @@ export const CATCH_ALL_REDIRECTS: CatchAllRedirect[] = [
* Add entries here for one-off page renames or removed pages.
*/
export const SINGLE_REDIRECTS: SingleRedirect[] = [
+ // Extensions CLI reference moved under the ThingsBoard CLI group
+ { oldPath: 'reference/extensions/cli-deployment', target: '/docs/user-guide/cli-extensions/' },
+ { oldPath: 'pe/reference/extensions/cli-deployment', target: '/docs/pe/user-guide/cli-extensions/' },
+ { oldPath: 'paas/reference/extensions/cli-deployment', target: '/docs/paas/user-guide/cli-extensions/' },
+ {
+ oldPath: 'paas/eu/reference/extensions/cli-deployment',
+ target: '/docs/paas/eu/user-guide/cli-extensions/',
+ },
{ oldPath: 'iot-gateway/configuration', target: '/docs/iot-gateway/config/general/' },
{ oldPath: 'iot-gateway/how-device-removing-renaming-works', target: '/docs/iot-gateway/features/device-renaming/' },
{ oldPath: 'iot-gateway/guides/how-to-configure-gateway-using-configurator', target: '/docs/iot-gateway/config/general/' },