diff --git a/.github/workflows/sdk_protos_map.csv b/.github/workflows/sdk_protos_map.csv
index bc9514a695..f0a3e41abb 100644
--- a/.github/workflows/sdk_protos_map.csv
+++ b/.github/workflows/sdk_protos_map.csv
@@ -83,9 +83,7 @@ button,GetResourceName,No,get_resource_name,Name,getResourceName,name
button,Close,No,close,Close,,
## Camera
-camera,GetImage,,get_image,Image,image,getImage
camera,GetImages,,get_images,Images,getImages,getImages
-camera,RenderFrame,,,,,renderFrame
camera,GetPointCloud,,get_point_cloud,NextPointCloud,pointCloud,getPointCloud
camera,GetProperties,,get_properties,Properties,properties,getProperties
## TED: Camera in Go SDK doesn't appear to implement (inherit) these:
@@ -173,6 +171,7 @@ motor,IsPowered,No,is_powered,IsPowered,powerState,isPowered
## HACK: No proto for these (and/or inherited in Go SDK), manually mapping:
motor,IsMoving,Yes,is_moving,IsMoving,isMoving,isMoving
motor,Stop,Yes,stop,Stop,stop,stop
+motor,GetGeometries,No,get_geometries,,,
motor,Reconfigure,No,,Reconfigure,,
# NOT implemented in other languages
motor,DoCommand,,do_command,DoCommand,doCommand,doCommand
@@ -229,6 +228,7 @@ servo,GetPosition,Yes,get_position,Position,position,getPosition
## HACK: No proto for these (and/or inherited in Go SDK), manually mapping:
servo,IsMoving,No,is_moving,IsMoving,isMoving,isMoving
servo,Stop,Yes,stop,Stop,stop,stop
+servo,GetGeometries,No,get_geometries,,,
servo,Reconfigure,No,,Reconfigure,,
# NOT implemented in other languages
servo,DoCommand,,do_command,DoCommand,doCommand,doCommand
@@ -254,7 +254,6 @@ base_remote_control,Close,,,Close,,
## Data Manager
data_manager,Sync,No,,Sync,,sync
-data_manager,UploadImageToDatasets,No,,UploadImageToDatasets,,,
data_manager,UploadBinaryDataToDatasets,No,,UploadBinaryDataToDatasets,,uploadBinaryDataToDatasets
## HACK: No proto for these (and/or inherited in Go SDK), manually mapping:
data_manager,Reconfigure,No,,Reconfigure,,
@@ -441,6 +440,7 @@ billing,GetOrgBillingInformation,,get_org_billing_information,GetOrgBillingInfor
billing,GetInvoicesSummary,,get_invoices_summary,GetInvoicesSummary,,getInvoicesSummary
billing,GetInvoicePDF,,get_invoice_pdf,GetInvoicePDF,,getInvoicePdf
billing,CreateInvoiceAndChargeImmediately,,create_invoice_and_charge_immediately,,,
+billing,ChargeOrganization,,charge_organization,,,
## Data
data,GetLatestTabularData,,get_latest_tabular_data,GetLatestTabularData,getLatestTabularData,getLatestTabularData
@@ -469,6 +469,13 @@ data,CreateDataPipeline,,create_data_pipeline,CreateDataPipeline,,createDataPipe
data,DeleteDataPipeline,,delete_data_pipeline,DeleteDataPipeline,,deleteDataPipeline
data,ListDataPipelineRuns,,list_data_pipeline_runs,ListDataPipelineRuns,,listDataPipelineRuns
data,RenameDataPipeline,,,RenameDataPipeline,,
+data,AddTagsToBinaryDataByFilter,,add_tags_to_binary_data_by_filter,,,
+data,CreateBinaryDataSignedURL,,create_binary_data_signed_url,,,
+data,CreateIndex,,create_index,,,
+data,DeleteIndex,,delete_index,,,
+data,ListIndexes,,list_indexes,,,
+data,RemoveTagsFromBinaryDataByFilter,,remove_tags_from_binary_data_by_filter,,,
+data,UpdateBoundingBox,,update_bounding_box,,,
## Dataset
dataset,CreateDataset,,create_dataset,CreateDataset,createDataset,createDataset
diff --git a/.htmltest-local.yml b/.htmltest-local.yml
index 6066d87071..54f0dc7fcc 100644
--- a/.htmltest-local.yml
+++ b/.htmltest-local.yml
@@ -9,6 +9,9 @@ IgnoreURLs:
- "rtk2go.com"
IgnoreDirs:
- "lib"
+ - "operate"
+ - "manage"
+ - "data-ai"
CacheExpires: "6h"
# IgnoreDirs: - if we need to ever ignore files
CheckExternal: false
\ No newline at end of file
diff --git a/assets/scss/_sidebar-tree.scss b/assets/scss/_sidebar-tree.scss
index 6cd2996d26..02bfe1e50b 100644
--- a/assets/scss/_sidebar-tree.scss
+++ b/assets/scss/_sidebar-tree.scss
@@ -187,7 +187,7 @@ li .indent {
}
.ul-2 > li:not(:last-child) {
- padding-bottom: 8px;
+ padding-bottom: 2px;
}
}
diff --git a/assets/scss/_styles_project.scss b/assets/scss/_styles_project.scss
index 39bdc445c2..b06a7ad257 100644
--- a/assets/scss/_styles_project.scss
+++ b/assets/scss/_styles_project.scss
@@ -177,6 +177,20 @@ h7,
.td-content > h3 {
margin-bottom: 0.667rem;
+ margin-top: 1.5rem;
+}
+
+// The float:left on ol > li::before creates floats that escape the ol's
+// block formatting context. Since h3 has clear:both, it clears past those
+// floats, creating an oversized gap. flow-root contains the floats in
+// the ol's own BFC so they don't affect adjacent elements.
+// Zero the last li's margin-bottom because flow-root prevents it from
+// collapsing with the ol's margin, which would add unwanted extra space.
+.td-content > ol {
+ display: flow-root;
+}
+.td-content > ol > li:last-child {
+ margin-bottom: 0;
}
/* END Adjust Heading sizes*/
@@ -3191,57 +3205,9 @@ nav {
background-color: rgb(232, 232, 234);
}
-.second-nav {
- min-width: 100%;
- padding: 0.25rem 1rem;
- border-top: 1px solid #e4e4e6;
- border-bottom: 1px solid #e4e4e6;
-}
-
-.second-nav > ul {
- padding: 0;
- padding-left: 0.25rem;
- margin: 0;
-}
-
-.second-nav > ul > li {
- display: inline-block;
- font-family:
- "Public Sans",
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- Roboto,
- "Helvetica Neue",
- Arial,
- sans-serif,
- "Apple Color Emoji",
- "Segoe UI Emoji",
- "Segoe UI Symbol";
- font-size: 0.875rem;
- line-height: 1.25rem;
- margin: 5px 0px;
- font-weight: 300;
-}
-
-.second-nav > ul > li > a {
- color: #333;
- padding: 5px 0.5rem;
- margin-right: 0.5rem;
-}
-
-.second-nav > ul > li > a:hover,
-.second-nav > ul > li > a.active-path {
- text-decoration: none;
- background-color: rgba(0, 0, 0, 0.03);
- border-radius: 4px;
- color: #282829;
- font-weight: 500;
-}
-
@media (min-width: 768px) {
.td-main main {
- padding-top: 7.5rem;
+ padding-top: 5rem;
}
}
@@ -3249,11 +3215,6 @@ nav {
min-height: 3.5rem;
}
-@media (max-width: 767px) {
- .second-nav {
- display: none;
- }
-}
@media (min-width: 992px) {
.d-lg-block {
@@ -3261,23 +3222,17 @@ nav {
}
}
-span.section-overview {
- display: none;
-}
ul > li.nav-fold > span > span.link-and-toggle {
display: flex !important;
flex-direction: row;
}
-ul > li.nav-fold:last-child {
+ul.ul-0 > li.nav-fold:last-child {
padding-bottom: 0.5rem;
}
@media (min-width: 768px) {
- .ul-1 > li.nav-fold.hide-if-desktop {
- display: none;
- }
#landing-page-sidebar {
display: none;
@@ -3290,24 +3245,38 @@ ul > li.nav-fold:last-child {
li.nav-fold.header-only > span > span {
color: #000000;
- margin-top: 1rem;
+ margin-top: 0.25rem;
}
li.nav-fold.header-only:first-child > span > a {
- margin-top: 0.5rem;
+ margin-top: 0.25rem;
}
li.nav-fold.header-only * li {
text-transform: unset;
}
- li > span > ul.ul-2 {
- padding-left: 0;
+ li.nav-top-section > span > span.link-and-toggle > a,
+ li.nav-top-section > a {
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+ color: #666;
+ }
+
+ li.nav-top-section > span > ul.ul-2 {
+ margin-left: 0.5rem;
}
ul.ul-2 > li > span > span {
color: #282829;
- font-weight: 600;
+ font-weight: 400;
+ font-size: 0.833rem;
+ }
+
+ ul.ul-2 > li > a {
+ font-size: 0.833rem;
}
li.header-only > span > ul.ul-3 {
@@ -3320,21 +3289,26 @@ ul > li.nav-fold:last-child {
padding-left: 0.5rem;
}
- span.section-overview-title {
- display: none;
- }
- .ul-1 > li.nav-fold > span > span.link-and-toggle {
- display: none !important;
- }
.td-sidebar-nav__section .ul-1 ul.ul-2 {
- padding-left: 0;
+ padding-left: 0.5rem;
+ border-left: 1px solid #e4e4e6;
+ margin-left: 0.5rem;
}
.td-sidebar-nav__section ul.ul-1 {
margin-left: 0;
}
+
+ li.nav-top-section {
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid #e4e4e6;
+ }
+
+ li.nav-top-section:last-child {
+ border-bottom: none;
+ }
}
.menu-toggle {
diff --git a/assets/tutorials/first-project/add-plus-button.png b/assets/tutorials/first-project/add-plus-button.png
new file mode 100644
index 0000000000..91f6cf66ed
Binary files /dev/null and b/assets/tutorials/first-project/add-plus-button.png differ
diff --git a/assets/tutorials/first-project/data-manager-search.png b/assets/tutorials/first-project/data-manager-search.png
new file mode 100644
index 0000000000..66a0b14c85
Binary files /dev/null and b/assets/tutorials/first-project/data-manager-search.png differ
diff --git a/assets/tutorials/first-project/gz-camera-search.png b/assets/tutorials/first-project/gz-camera-search.png
new file mode 100644
index 0000000000..b5e6389be6
Binary files /dev/null and b/assets/tutorials/first-project/gz-camera-search.png differ
diff --git a/assets/tutorials/first-project/try-vision-pipeline-fragment.png b/assets/tutorials/first-project/try-vision-pipeline-fragment.png
new file mode 100644
index 0000000000..2b2a776b9c
Binary files /dev/null and b/assets/tutorials/first-project/try-vision-pipeline-fragment.png differ
diff --git a/config.toml b/config.toml
index 2f47b84c1f..6c870a4af5 100644
--- a/config.toml
+++ b/config.toml
@@ -115,9 +115,9 @@ breadcrumb_disable = false
footer_about_disable = false
navbar_logo = true
navbar_translucent_over_cover_disable = false
-sidebar_menu_compact = true # When true, the section headings work like an accordian.
+sidebar_menu_compact = true # When true, only the active path is expanded in the sidebar.
sidebar_search_disable = true
-ul_show = 3 # Always expand every first level section of the sidenav -- due to tabs, our 'first level' is 3
+ul_show = 1 # Show only top-level sections; deeper levels collapse unless active
[params.ui.feedback]
enable = true
diff --git a/docs/_index.md b/docs/_index.md
index 5da8c88295..e3023a9295 100644
--- a/docs/_index.md
+++ b/docs/_index.md
@@ -3,17 +3,14 @@ title: "Viam Documentation"
linkTitle: "Viam Documentation"
description: "Viam integrates with hardware and software on any device. Use AI, machine learning, and more to make any machine smarter — for one machine to thousands."
weight: 1
-no_list: true
type: "docs"
-noToc: true
-hide_feedback: true
+layout: "empty"
+canonical: "/what-is-viam/"
sitemap:
priority: 1.0
outputs:
- html
- REDIR
-imageAlt: "/general/understand.png"
-images: ["/general/understand.png"]
noedit: true
date: "2024-09-17"
updated: "2024-10-11"
@@ -26,69 +23,3 @@ aliases:
- "/get-started/"
- "/platform/"
---
-
-
-
-
-
Viam Documentation
-
- Viam integrates with hardware and software on any device in the physical world. Once you set up your machines , you can use Viam SDKs to program your devices and connected hardware. Everything is managed in the cloud and you can use machine learning, data management, and much more for your projects.
-
-
-
-
-
-
-
-
-
-
-
-
Viam allows you to control and program any sensor, actuator or other hardware that is connected to a device. The Viam platform offers builtin capabilities to capture data from devices to the cloud, to build and deploy machine learning models, to alert on problems, and much more. With the connection to the cloud, you can configure, control, and manage your devices from anywhere.
-
-
-
-
-
-
-
diff --git a/docs/build-apps/_index.md b/docs/build-apps/_index.md
new file mode 100644
index 0000000000..6599d00c95
--- /dev/null
+++ b/docs/build-apps/_index.md
@@ -0,0 +1,20 @@
+---
+linkTitle: "Build apps"
+title: "Build apps"
+weight: 65
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/build-apps/overview/"
+description: "Build client apps that talk to your Viam machines and the Viam cloud."
+date: "2026-04-10"
+aliases:
+ # The following aliases are deferred. The pages listed still exist in
+ # the hidden operate/control/ and tutorials/control/ sections. Activate
+ # these aliases in the PR that deletes the old pages.
+ - /operate/control/web-app/
+ - /operate/control/mobile-app/
+ - /operate/control/viam-applications/
+ - /operate/control/kiosk-app/
+ - /operate/control/voice-app/
+---
diff --git a/docs/build-apps/app-tutorials/_index.md b/docs/build-apps/app-tutorials/_index.md
new file mode 100644
index 0000000000..e7686fe1b6
--- /dev/null
+++ b/docs/build-apps/app-tutorials/_index.md
@@ -0,0 +1,10 @@
+---
+linkTitle: "App tutorials"
+title: "App tutorials"
+weight: 100
+layout: "docs"
+type: "docs"
+no_list: true
+description: "Guided projects that walk through building a Viam app from start to finish."
+date: "2026-04-13"
+---
diff --git a/docs/build-apps/app-tutorials/tutorial-dashboard.md b/docs/build-apps/app-tutorials/tutorial-dashboard.md
new file mode 100644
index 0000000000..011c4aa140
--- /dev/null
+++ b/docs/build-apps/app-tutorials/tutorial-dashboard.md
@@ -0,0 +1,280 @@
+---
+linkTitle: "Tutorial: single-machine dashboard"
+title: "Build a single-machine dashboard"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "Build a TypeScript web dashboard for a single Viam machine. Displays a camera feed, a live sensor reading, and a motor control button, with a connection status indicator."
+date: "2026-04-10"
+---
+
+In this tutorial, you will build a browser-based dashboard for a single Viam machine. The finished dashboard shows:
+
+- A live camera feed
+- The most recent reading from a sensor
+- A start/stop button for a motor
+- A connection status indicator
+
+You will learn the four patterns that almost every Viam client app uses: opening a connection, reading state from a component, changing state on a component, and reacting to connection events. The dashboard runs locally in your browser by the end; deployment is out of scope for this tutorial.
+
+The tutorial uses vanilla TypeScript and Vite without any frontend framework. The patterns work the same in React, Vue, or Svelte; a vanilla setup keeps the SDK calls visible without framework ceremony.
+
+## What you need
+
+- A configured Viam machine with a camera, a sensor, and a motor. Any models work. If you do not have the physical hardware, add fake components in the Viam app's **CONFIGURE** tab: `fake:camera`, `fake:sensor`, and `fake:motor`. The fake components respond to SDK calls the same way real ones do.
+- A completed [TypeScript setup](/build-apps/setup/typescript/). You should have a project directory with `@viamrobotics/sdk` installed, a `.env` file holding your machine credentials, and `index.html` plus `src/main.ts` files from the setup page.
+- Two browser windows side by side, or two tabs you can switch between. One window runs your dashboard; the other opens the Viam app's **CONTROL** tab for the same machine so you can see server-side state change when your code runs.
+
+Before continuing, confirm your setup by running `npx vite` and verifying that the page from the setup step shows `Connected. Found N resources.` in the browser. If it does not, go back to [TypeScript setup](/build-apps/setup/typescript/) and fix the connection before continuing.
+
+## Step 1: Replace the HTML
+
+Open `index.html` and replace its contents with a layout that has slots for each piece of the dashboard:
+
+```html
+
+
+
+
+ My Viam Dashboard
+
+
+
+ My Viam Dashboard
+ Status: Connecting...
+
+
+
Camera
+
+
+
+
+
+
+
Motor
+ Start motor
+ Stop motor
+
+
+
+
+
+```
+
+Save the file. If Vite is still running, it reloads automatically. You should see the page layout in your browser with empty values and non-functional buttons.
+
+## Step 2: Connect to your machine
+
+Open `src/main.ts` and replace its contents with a connection that stores the machine client as a module-level variable. You will add the dashboard logic on top of this connection in the steps that follow.
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const statusEl = document.getElementById("status") as HTMLSpanElement;
+const cameraEl = document.getElementById("camera") as HTMLVideoElement;
+const sensorEl = document.getElementById("sensor") as HTMLPreElement;
+const startBtn = document.getElementById("start") as HTMLButtonElement;
+const stopBtn = document.getElementById("stop") as HTMLButtonElement;
+
+let machine: VIAM.RobotClient;
+
+async function main() {
+ machine = await VIAM.createRobotClient({
+ host: import.meta.env.VITE_HOST,
+ credentials: {
+ type: "api-key",
+ authEntity: import.meta.env.VITE_API_KEY_ID,
+ payload: import.meta.env.VITE_API_KEY,
+ },
+ signalingAddress: "https://app.viam.com:443",
+ });
+
+ statusEl.textContent = "Connected";
+ statusEl.className = "connected";
+}
+
+main().catch((err) => {
+ statusEl.textContent = `Connection failed: ${err.message ?? err}`;
+ statusEl.className = "disconnected";
+});
+```
+
+Save the file. Refresh the browser. The status line should change from `Connecting...` to `Connected` in green. If it shows `Connection failed:`, check that your `.env` file has the right credentials and that your machine is online in the Viam app.
+
+## Step 3: Display the camera feed
+
+Add a camera stream to the dashboard. Use a `StreamClient` to attach the camera's WebRTC stream to the `` element:
+
+```ts
+async function startCamera() {
+ const streamClient = new VIAM.StreamClient(machine);
+ const mediaStream = await streamClient.getStream("camera");
+ cameraEl.srcObject = mediaStream;
+}
+```
+
+Call `startCamera()` at the end of `main()`, after the connection is established:
+
+```ts
+async function main() {
+ machine = await VIAM.createRobotClient({
+ // ... (unchanged)
+ });
+
+ statusEl.textContent = "Connected";
+ statusEl.className = "connected";
+
+ await startCamera();
+}
+```
+
+The string `"camera"` is the component name you gave the camera in your machine config. If you named it something else (like `my_webcam` or `fake_camera`), change the argument to match.
+
+Save and refresh. The camera panel should now show a live feed. For a real camera, you will see the camera's image; for `fake:camera`, you will see a test pattern.
+
+## Step 4: Poll the sensor
+
+Sensors do not push data; you call `getReadings()` on a timer and display whatever you get back:
+
+```ts
+function startSensorPolling() {
+ const sensor = new VIAM.SensorClient(machine, "sensor");
+
+ setInterval(async () => {
+ try {
+ const readings = await sensor.getReadings();
+ sensorEl.textContent = JSON.stringify(readings, null, 2);
+ } catch (err) {
+ sensorEl.textContent = `Error: ${err}`;
+ }
+ }, 1000);
+}
+```
+
+Call `startSensorPolling()` after `startCamera()` in `main()`. Change the sensor name `"sensor"` if yours is configured differently.
+
+Save and refresh. The sensor panel now updates every second with the current readings from your sensor. For `fake:sensor`, this is usually a single key like `"reading": 0.5` that changes over time. For a real sensor, you see whatever values the component exposes.
+
+## Step 5: Control the motor
+
+Wire the Start and Stop buttons to `motor.setPower(1)` and `motor.stop()`:
+
+```ts
+function wireMotorButtons() {
+ const motor = new VIAM.MotorClient(machine, "motor");
+
+ startBtn.addEventListener("click", async () => {
+ try {
+ await motor.setPower(1);
+ } catch (err) {
+ console.error("setPower failed:", err);
+ }
+ });
+
+ stopBtn.addEventListener("click", async () => {
+ try {
+ await motor.stop();
+ } catch (err) {
+ console.error("stop failed:", err);
+ }
+ });
+}
+```
+
+Call `wireMotorButtons()` after `startSensorPolling()` in `main()`. Change the motor name `"motor"` if yours is configured differently.
+
+Save and refresh. Now open a second browser window to the Viam app's **CONTROL** tab for the same machine. Arrange the two windows side by side.
+
+Click **Start motor** in your dashboard. In the Viam app's Control tab, the motor's power slider moves to 1 (full power). Click **Stop motor**. The slider returns to 0. You just made a server-side state change from your own app code, and you can see it reflected in another client watching the same machine.
+
+For a `fake:motor`, the motor has no physical effect, but the state change is real. The same API call on a real motor would spin it at full power.
+
+## Step 6: Add connection state handling
+
+The dashboard is functional, but it does not react if the connection drops. Add a connection-state listener so the status indicator updates when the network changes:
+
+```ts
+function watchConnection() {
+ machine.on("connectionstatechange", (event) => {
+ const { eventType } = event as { eventType: VIAM.MachineConnectionEvent };
+ switch (eventType) {
+ case VIAM.MachineConnectionEvent.CONNECTED:
+ statusEl.textContent = "Connected";
+ statusEl.className = "connected";
+ break;
+ case VIAM.MachineConnectionEvent.DIALING:
+ case VIAM.MachineConnectionEvent.CONNECTING:
+ statusEl.textContent = "Connecting...";
+ statusEl.className = "";
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTING:
+ statusEl.textContent = "Reconnecting...";
+ statusEl.className = "";
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTION_FAILED:
+ case VIAM.MachineConnectionEvent.DISCONNECTING:
+ case VIAM.MachineConnectionEvent.DISCONNECTED:
+ statusEl.textContent = "Disconnected";
+ statusEl.className = "disconnected";
+ break;
+ }
+ });
+}
+```
+
+Call `watchConnection()` at the end of `main()`, after the motor buttons are wired.
+
+Save and refresh. The status indicator still says `Connected` on load. To test the state change, turn off your machine or disconnect your computer from the network briefly. The indicator switches to `Reconnecting...` while the SDK retries with backoff. Restore connectivity and the SDK reconnects automatically, switching the indicator back to `Connected` (green). If reconnection attempts are exhausted, the SDK emits `RECONNECTION_FAILED` and the indicator switches to `Disconnected`. The camera stream may or may not resume on its own depending on how long the disconnection lasted; see [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for the rebuild-after-reconnect pattern.
+
+## What you built
+
+You now have a single-file TypeScript dashboard that:
+
+- Opens a connection to a Viam machine at startup
+- Displays a live camera feed
+- Polls and displays sensor readings every second
+- Controls a motor through start and stop buttons
+- Reflects connection state in a status indicator
+
+The full `src/main.ts` is around 80 lines of code. The four patterns you used (connect, read state, change state, react to events) cover almost every interactive Viam client app. When you build a larger app, you structure it around the same four operations, just with more components and a UI framework layered on top.
+
+## Next steps
+
+Extend the dashboard in one of these directions:
+
+- **Auto-stop the motor when the sensor crosses a threshold.** In the sensor polling loop, check the reading's value and call `motor.stop()` when it exceeds a limit. This is the smallest useful control loop: reads drive writes. Combining observation with action is the core pattern of all robotics software.
+- **Add a second camera.** Instantiate another `StreamClient.getStream("second_camera")` call and attach the result to a second `` element. See [Stream video](/build-apps/tasks/stream-video/) for the multi-camera section and its bandwidth caveats.
+- **Rebuild state after reconnection.** The current dashboard does not re-attach the camera stream after a long disconnection. Follow [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) to add the rebuild pattern.
+- **Deploy the dashboard to Viam Applications.** Follow [Deploy a Viam application](/build-apps/hosting/deploy/) to host the dashboard at a public URL with authentication and cookie-injected credentials. The code you wrote here works the same when deployed, except you read credentials from cookies instead of `import.meta.env`.
+- **Build a multi-machine version.** See [the fleet tutorial](/build-apps/app-tutorials/tutorial-fleet/) for a dashboard that connects to the Viam cloud and aggregates data across several machines.
diff --git a/docs/build-apps/app-tutorials/tutorial-fleet.md b/docs/build-apps/app-tutorials/tutorial-fleet.md
new file mode 100644
index 0000000000..cb4a7ae26c
--- /dev/null
+++ b/docs/build-apps/app-tutorials/tutorial-fleet.md
@@ -0,0 +1,376 @@
+---
+linkTitle: "Tutorial: multi-machine fleet dashboard"
+title: "Build a multi-machine fleet dashboard"
+weight: 120
+layout: "docs"
+type: "docs"
+description: "Build a TypeScript web dashboard that connects to the Viam cloud, enumerates machines across an organization, and displays aggregated sensor data from a fleet."
+date: "2026-04-10"
+---
+
+In this tutorial, you will build a browser-based dashboard that reads captured sensor data from a fleet of Viam machines and displays aggregated values per machine. The finished dashboard:
+
+- Connects to the Viam cloud using a user API key
+- Lists the machines in your organization
+- Runs an MQL aggregation query over the last hour of sensor readings for each machine
+- Renders the results as a table
+
+You will learn the patterns for working with multi-machine Viam apps: connecting to the cloud rather than to one machine, enumerating resources across an organization, and aggregating captured data with MQL. The dashboard runs locally in your browser for most of the tutorial, with an optional final step to deploy it as a hosted Viam Application.
+
+The tutorial uses the air-quality use case as a concrete example: each machine has an air quality sensor that captures PM2.5 readings, and the dashboard shows the average for each machine over the last hour. The patterns work the same for any fleet and any captured data; substitute your own sensor type and field names as needed.
+
+## What you need
+
+- A Viam organization with at least two machines configured. Any machines work as long as each has a sensor you can capture data from. For the tutorial's air-quality framing, each machine has a sensor that returns a PM2.5 value under a field named `pm_2_5`, but you can use any field name as long as you update the MQL query in step 4 to match.
+- Data capture configured on the sensors, with enough data synced to the cloud that there are recent readings to aggregate. See [Capture and sync data](/data/capture-sync/capture-and-sync-data/) if you need to set this up.
+- An organization-scoped or location-scoped API key and its ID. Create one in [Admin and access](/organization/access/). A machine-scoped key will not work for this tutorial because you need access to multiple machines.
+- A completed [TypeScript setup](/build-apps/setup/typescript/) with Vite, the Viam TypeScript SDK, a `.env` file, and an `index.html` plus `src/main.ts` from the setup page.
+- Your organization ID. Find it in the Viam app by clicking your organization name and selecting **Settings**.
+
+Before continuing, update your `.env` file to use an organization-scoped API key instead of a machine-scoped one, and add your organization ID:
+
+```text
+VITE_API_KEY_ID=your-org-api-key-id
+VITE_API_KEY=your-org-api-key-secret
+VITE_ORG_ID=your-organization-id
+```
+
+You no longer need `VITE_HOST` for this tutorial; the cloud client does not connect to a specific machine address.
+
+## Step 1: Replace the HTML
+
+Open `index.html` and replace its contents:
+
+```html
+
+
+
+
+ Fleet Dashboard
+
+
+
+ Fleet Dashboard
+ Connecting...
+
+
+
+
+```
+
+Save. If Vite is running, it reloads automatically.
+
+## Step 2: Connect to the Viam cloud
+
+Replace the contents of `src/main.ts` with a cloud connection that uses `createViamClient` instead of `createRobotClient`:
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const statusEl = document.getElementById("status") as HTMLParagraphElement;
+const dashboardEl = document.getElementById("dashboard") as HTMLDivElement;
+
+const ORG_ID = import.meta.env.VITE_ORG_ID;
+
+let client: VIAM.ViamClient;
+
+async function main() {
+ client = await VIAM.createViamClient({
+ credentials: {
+ type: "api-key",
+ authEntity: import.meta.env.VITE_API_KEY_ID,
+ payload: import.meta.env.VITE_API_KEY,
+ },
+ });
+
+ statusEl.textContent = "Connected to Viam cloud";
+}
+
+main().catch((err) => {
+ statusEl.textContent = `Connection failed: ${err.message ?? err}`;
+});
+```
+
+Save and refresh. The status line should change to `Connected to Viam cloud`. If you see `Connection failed:`, check that the API key you put in `.env` is organization-scoped (not machine-scoped) and that the organization ID matches your Viam account.
+
+Unlike `createRobotClient`, the cloud client does not open a WebRTC connection. It holds a transport that makes HTTP-based gRPC calls to the Viam cloud API. Each subsequent method call is a separate request.
+
+## Step 3: List the machines in the organization
+
+Add a function that lists all machines across all locations in your organization using `appClient.listMachineSummaries`:
+
+```ts
+interface Machine {
+ id: string;
+ name: string;
+ locationName: string;
+}
+
+async function listMachines(): Promise {
+ const summaries = await client.appClient.listMachineSummaries(ORG_ID);
+ const machines: Machine[] = [];
+ for (const location of summaries) {
+ for (const m of location.machines) {
+ machines.push({
+ id: m.machineId,
+ name: m.machineName,
+ locationName: location.locationName,
+ });
+ }
+ }
+ return machines;
+}
+```
+
+Call `listMachines()` at the end of `main()` and log the result:
+
+```ts
+async function main() {
+ client = await VIAM.createViamClient({
+ // ... (unchanged)
+ });
+
+ statusEl.textContent = "Connected to Viam cloud";
+
+ const machines = await listMachines();
+ console.log(`Found ${machines.length} machines`);
+ for (const m of machines) {
+ console.log(` ${m.name} (${m.id}) in ${m.locationName}`);
+ }
+}
+```
+
+Save and refresh. Open the browser's developer console. You should see a list of every machine in your organization with its name, ID, and location. If you have more than one location, machines from all of them are listed.
+
+## Step 4: Query aggregated data
+
+Now run an MQL aggregation query for each machine to get the average PM2.5 reading over the last hour. The TypeScript SDK accepts plain JavaScript objects for MQL queries and serializes them to BSON internally:
+
+```ts
+interface MachineReading {
+ machineId: string;
+ machineName: string;
+ locationName: string;
+ avgPm25: number | null;
+ sampleCount: number;
+}
+
+async function getReadingForMachine(m: Machine): Promise {
+ const oneHourAgo = new Date(Date.now() - 3600 * 1000);
+
+ const pipeline = [
+ {
+ $match: {
+ robot_id: m.id,
+ component_name: "air_quality_sensor",
+ time_received: { $gte: oneHourAgo },
+ },
+ },
+ {
+ $group: {
+ _id: null,
+ avgPm25: { $avg: "$data.readings.pm_2_5" },
+ sampleCount: { $sum: 1 },
+ },
+ },
+ ];
+
+ const results = await client.dataClient.tabularDataByMQL(ORG_ID, pipeline);
+
+ if (results.length === 0) {
+ return {
+ machineId: m.id,
+ machineName: m.name,
+ locationName: m.locationName,
+ avgPm25: null,
+ sampleCount: 0,
+ };
+ }
+
+ const row = results[0] as { avgPm25: number; sampleCount: number };
+ return {
+ machineId: m.id,
+ machineName: m.name,
+ locationName: m.locationName,
+ avgPm25: row.avgPm25,
+ sampleCount: row.sampleCount,
+ };
+}
+```
+
+The `$match` stage filters the captured data to:
+
+- Only the current machine's readings (`robot_id` matches)
+- Only the air quality sensor component (change `"air_quality_sensor"` to your sensor's component name)
+- Only the last hour of data
+
+The `$group` stage computes the average of the `pm_2_5` field across all matching readings and counts how many samples contributed to the average. Change `$data.readings.pm_2_5` to the actual field path your sensor captures.
+
+Add a function that runs the query for every machine in parallel:
+
+```ts
+async function getFleetReadings(
+ machines: Machine[],
+): Promise {
+ return Promise.all(machines.map(getReadingForMachine));
+}
+```
+
+Update `main()` to call it and log the results:
+
+```ts
+async function main() {
+ client = await VIAM.createViamClient({
+ // ... (unchanged)
+ });
+
+ statusEl.textContent = "Connected to Viam cloud";
+
+ const machines = await listMachines();
+ const readings = await getFleetReadings(machines);
+ console.log(readings);
+}
+```
+
+Save and refresh. The console now shows one entry per machine with the average PM2.5 value and sample count. If a machine has no data in the last hour, its `avgPm25` is `null` and its `sampleCount` is `0`.
+
+## Step 5: Render the dashboard
+
+Replace the console logging with a table rendered into the `#dashboard` element:
+
+```ts
+function categorize(pm25: number | null): string {
+ if (pm25 === null) return "";
+ if (pm25 < 12) return "good";
+ if (pm25 < 35) return "moderate";
+ return "unhealthy";
+}
+
+function renderDashboard(readings: MachineReading[]) {
+ const rows = readings
+ .map((r) => {
+ const category = categorize(r.avgPm25);
+ const value = r.avgPm25 === null ? "—" : `${r.avgPm25.toFixed(1)} µg/m³`;
+ return `
+
+ ${r.machineName}
+ ${r.locationName}
+ ${value}
+ ${r.sampleCount}
+
+ `;
+ })
+ .join("");
+
+ dashboardEl.innerHTML = `
+
+
+
+ Machine
+ Location
+ Avg PM2.5 (last hour)
+ Samples
+
+
+ ${rows}
+
+ `;
+}
+```
+
+Replace the `console.log(readings)` in `main()` with a call to `renderDashboard(readings)`:
+
+```ts
+const readings = await getFleetReadings(machines);
+renderDashboard(readings);
+statusEl.textContent = `Showing ${readings.length} machines`;
+```
+
+Save and refresh. You should now see a table with one row per machine, showing the machine name, location, average PM2.5 value over the last hour, and the number of samples that contributed to the average. Values below 12 µg/m³ display in green (good), 12–35 in orange (moderate), and above 35 in red (unhealthy).
+
+The category thresholds follow the United States EPA's air quality index breakpoints for PM2.5. Use whatever thresholds are appropriate for the data you are actually showing.
+
+## Step 6: Refresh on a timer
+
+A fleet dashboard is most useful when it updates on its own. Wrap the query-and-render in a function and run it on a 30-second interval:
+
+```ts
+async function refresh() {
+ try {
+ const machines = await listMachines();
+ const readings = await getFleetReadings(machines);
+ renderDashboard(readings);
+ statusEl.textContent = `Last updated ${new Date().toLocaleTimeString()}, ${readings.length} machines`;
+ } catch (err) {
+ statusEl.textContent = `Update failed: ${(err as Error).message}`;
+ }
+}
+```
+
+Replace the manual calls in `main()` with one initial call to `refresh()` followed by a `setInterval`:
+
+```ts
+async function main() {
+ client = await VIAM.createViamClient({
+ // ... (unchanged)
+ });
+
+ await refresh();
+ setInterval(refresh, 30_000);
+}
+```
+
+Save and refresh. The dashboard updates every 30 seconds. The status line shows the last update time and the number of machines displayed. If a refresh fails (network error, query timeout), the status line shows the error but the previous dashboard state stays on screen.
+
+## What you built
+
+You now have a multi-machine dashboard that:
+
+- Connects to the Viam cloud with an organization-scoped API key
+- Enumerates every machine in the organization across all locations
+- Runs an MQL aggregation query for each machine to compute the average PM2.5 reading over the last hour
+- Renders the results as a color-coded table
+- Refreshes every 30 seconds
+
+The full `src/main.ts` is around 130 lines. The patterns you used (cloud client, machine enumeration, MQL aggregation, periodic refresh) are the same patterns any multi-machine Viam app uses. Whether your fleet monitors air quality, tracks warehouse rovers, or aggregates manufacturing telemetry, the dashboard shape is the same.
+
+## Deploy as a Viam Application (optional)
+
+The dashboard runs locally right now. To host it at a Viam URL with built-in authentication, follow [Deploy a Viam application](/build-apps/hosting/deploy/). The key change when deploying: instead of reading credentials from `import.meta.env`, your deployed app reads them from a browser cookie that Viam injects after the user logs in.
+
+Replace the `createViamClient` call with code that reads the access token from the `userToken` cookie. The [hosting platform reference](/build-apps/hosting/hosting-reference/) documents the exact cookie format. When deployed as a multi-machine Viam Application, the rest of the dashboard code works unchanged; only the credential loading changes.
+
+## Next steps
+
+- **Filter by fragment.** If you configured the machines in your fleet with a shared fragment, pass `fragmentIds` to `listMachineSummaries` to scope the dashboard to only machines that include that fragment. Useful for multi-tenant apps where one organization has several customer fleets.
+- **Show historical trends.** Change the MQL pipeline to use `$bucket` or `$bucketAuto` to group readings into time buckets, then render a chart instead of a single aggregate value. [Query captured data](/build-apps/tasks/query-data/) covers more aggregation patterns.
+- **Aggregate across multiple sensor types.** Run a different MQL pipeline for each sensor you care about (PM2.5, PM10, VOC, temperature) and show all of them in the same row per machine.
+- **Add a machine detail view.** When the user clicks a row, open a second view that queries minute-by-minute data for that one machine. The common fleet dashboard pattern is an overview table plus a detail view per machine.
+- **Deploy to Viam Applications.** Follow [Deploy a Viam application](/build-apps/hosting/deploy/) for the packaging and upload workflow.
diff --git a/docs/build-apps/app-tutorials/tutorial-flutter-app.md b/docs/build-apps/app-tutorials/tutorial-flutter-app.md
new file mode 100644
index 0000000000..b1afe5e767
--- /dev/null
+++ b/docs/build-apps/app-tutorials/tutorial-flutter-app.md
@@ -0,0 +1,279 @@
+---
+linkTitle: "Tutorial: Flutter app with widgets"
+title: "Build a Flutter app with widgets"
+weight: 110
+layout: "docs"
+type: "docs"
+description: "Build a cross-platform Flutter app for a single Viam machine. Uses prebuilt widgets for the camera feed, sensor display, and motor control."
+date: "2026-04-10"
+---
+
+In this tutorial, you will build a Flutter app for a single Viam machine using the widgets that ship with the Flutter SDK. The finished app shows:
+
+- A live camera feed (`ViamCameraStreamView`)
+- A live sensor readings table (`ViamSensorWidget`)
+- A motor control widget (`ViamMotorWidget`)
+
+You will learn the Flutter SDK's widget-driven pattern, which is faster than writing stream and polling logic by hand. You will also see that the same Flutter app builds and runs on iOS, Android, and desktop from one codebase.
+
+This tutorial uses the Flutter-specific widget path rather than the raw SDK pattern. For a comparison using raw SDK calls, see [the TypeScript dashboard tutorial](/build-apps/app-tutorials/tutorial-dashboard/).
+
+## What you need
+
+- A configured Viam machine with a camera, a sensor, and a motor. Any models work. If you do not have the physical hardware, add fake components in the Viam app's **CONFIGURE** tab: `fake:camera`, `fake:sensor`, and `fake:motor`.
+- A completed [Flutter setup](/build-apps/setup/flutter/). You should have a Flutter project with `viam_sdk` and `flutter_dotenv` installed, a `.env` file holding your machine credentials, and the iOS/Android platform configuration applied.
+- A target platform to run the app on: an iOS simulator, an Android emulator, a physical device, or a desktop target (macOS, Linux, Windows).
+
+Before continuing, confirm your setup by running `flutter run` and verifying that the app from the setup step shows `Connected. Found N resources.` If it does not, go back to [Flutter setup](/build-apps/setup/flutter/) and fix the connection before continuing.
+
+## Step 1: Set up the app skeleton
+
+Replace the contents of `lib/main.dart` with a new app skeleton that defines a home screen with space for the three widgets and a connection indicator:
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:viam_sdk/viam_sdk.dart';
+import 'package:viam_sdk/widgets.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await dotenv.load();
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return MaterialApp(
+ title: 'My Viam Dashboard',
+ theme: ThemeData(useMaterial3: true),
+ home: const DashboardScreen(),
+ );
+ }
+}
+
+class DashboardScreen extends StatefulWidget {
+ const DashboardScreen({super.key});
+
+ @override
+ State createState() => _DashboardScreenState();
+}
+
+class _DashboardScreenState extends State {
+ RobotClient? _robot;
+ String _status = 'Connecting...';
+
+ @override
+ void initState() {
+ super.initState();
+ _connect();
+ }
+
+ @override
+ void dispose() {
+ _robot?.close();
+ super.dispose();
+ }
+
+ Future _connect() async {
+ try {
+ final robot = await RobotClient.atAddress(
+ dotenv.env['MACHINE_ADDRESS']!,
+ RobotClientOptions.withApiKey(
+ dotenv.env['API_KEY_ID']!,
+ dotenv.env['API_KEY']!,
+ ),
+ );
+ setState(() {
+ _robot = robot;
+ _status = 'Connected';
+ });
+ } catch (e) {
+ setState(() {
+ _status = 'Connection failed: $e';
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('My Viam Dashboard')),
+ body: SingleChildScrollView(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('Status: $_status'),
+ const SizedBox(height: 16),
+ if (_robot != null) ..._buildDashboard(_robot!),
+ ],
+ ),
+ ),
+ );
+ }
+
+ List _buildDashboard(RobotClient robot) {
+ return const [
+ Text('Dashboard will go here'),
+ ];
+ }
+}
+```
+
+Save the file and run `flutter run`. Pick your target platform when prompted. The app builds, launches, and shows the app bar with "My Viam Dashboard" and the status text switching from `Connecting...` to `Connected`. The "Dashboard will go here" placeholder appears below the status once the connection is established.
+
+You will replace the placeholder in the next three steps.
+
+## Step 2: Add the camera widget
+
+Update `_buildDashboard` to include `ViamCameraStreamView`:
+
+```dart
+List _buildDashboard(RobotClient robot) {
+ final camera = Camera.fromRobot(robot, 'camera');
+ final streamClient = robot.getStream('camera');
+
+ return [
+ const Text('Camera', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 240,
+ child: ViamCameraStreamView(
+ camera: camera,
+ streamClient: streamClient,
+ ),
+ ),
+ ];
+}
+```
+
+The string `'camera'` is the component name you gave the camera in your machine config. Change it to match your config if you used a different name.
+
+Save the file. Flutter's hot reload updates the app without rebuilding. The camera panel now shows a live feed. For a real camera, you see the camera's image; for `fake:camera`, you see a test pattern.
+
+`ViamCameraStreamView` is a stateful widget that manages the `RTCVideoRenderer` lifecycle, initializes the WebRTC stream, tears it down on dispose, and displays an error state if the stream fails. You did not write any of that logic; the widget handles it all.
+
+## Step 3: Add the sensor widget
+
+Extend `_buildDashboard` to append a `ViamSensorWidget`:
+
+```dart
+List _buildDashboard(RobotClient robot) {
+ final camera = Camera.fromRobot(robot, 'camera');
+ final streamClient = robot.getStream('camera');
+ final sensor = Sensor.fromRobot(robot, 'sensor');
+
+ return [
+ const Text('Camera', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 240,
+ child: ViamCameraStreamView(
+ camera: camera,
+ streamClient: streamClient,
+ ),
+ ),
+ const SizedBox(height: 24),
+ const Text('Sensor readings', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ ViamSensorWidget(sensor: sensor),
+ ];
+}
+```
+
+Save. The app now shows a data table under the camera, populated with the sensor's current readings. The widget refreshes the table on its own; you do not need a timer or a polling loop.
+
+Under the hood, `ViamSensorWidget` is a `ViamRefreshableDataTable` that calls `sensor.readings()` on a schedule and re-renders when new data arrives. The Flutter SDK's widget layer handles the polling so you do not have to.
+
+## Step 4: Add the motor widget
+
+Append a `ViamMotorWidget` to the dashboard:
+
+```dart
+List _buildDashboard(RobotClient robot) {
+ final camera = Camera.fromRobot(robot, 'camera');
+ final streamClient = robot.getStream('camera');
+ final sensor = Sensor.fromRobot(robot, 'sensor');
+ final motor = Motor.fromRobot(robot, 'motor');
+
+ return [
+ const Text('Camera', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ SizedBox(
+ height: 240,
+ child: ViamCameraStreamView(
+ camera: camera,
+ streamClient: streamClient,
+ ),
+ ),
+ const SizedBox(height: 24),
+ const Text('Sensor readings', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ ViamSensorWidget(sensor: sensor),
+ const SizedBox(height: 24),
+ const Text('Motor', style: TextStyle(fontSize: 20)),
+ const SizedBox(height: 8),
+ ViamMotorWidget(motor: motor),
+ ];
+}
+```
+
+Save. The motor section now shows a power slider with auto-stop behavior. Drag the slider to set a power level; `ViamMotorWidget` calls `motor.setPower()` on the underlying component as you adjust it. The widget's auto-stop mode means releasing the slider calls `motor.stop()` so the motor does not continue running at the last commanded power when you let go.
+
+Open the Viam app's **CONTROL** tab for the same machine in another window. Arrange the two side by side. When you drag the slider in your Flutter app, the motor's power slider in the Viam app's Control tab moves in sync. You just made a server-side state change from your Flutter app, visible to another client watching the same machine.
+
+For `fake:motor`, the motor has no physical effect, but the state change is real. The same calls on a real motor would spin it.
+
+## Step 5: Run on another platform
+
+Stop the app and run it on a second target to see the cross-platform story. If you first ran it on an iOS simulator, try Android, or try a desktop target:
+
+```sh {class="command-line" data-prompt="$"}
+flutter run -d macos
+```
+
+Or:
+
+```sh {class="command-line" data-prompt="$"}
+flutter run -d chrome
+```
+
+Or:
+
+```sh {class="command-line" data-prompt="$"}
+flutter run -d windows
+```
+
+The same code builds and runs on each platform without modification. The widgets render with the platform's Material look, the camera stream decodes and displays on each target, the sensor data table updates, and the motor slider controls the same machine. This is the argument for Flutter over a browser-only framework when you need one app that runs across iOS, Android, and one or more desktop operating systems.
+
+Some platform-specific notes:
+
+- **iOS and macOS** require the `Info.plist` permissions you added in [Flutter setup](/build-apps/setup/flutter/) for WebRTC to work.
+- **Android** requires minimum SDK 23 and the setup page's Kotlin version.
+- **Web** support depends on whether `flutter_webrtc` has stable web behavior for your specific build. If the web target fails, fall back to native or desktop targets for now.
+
+## What you built
+
+You now have a Flutter app that:
+
+- Connects to a Viam machine at startup and closes the connection on dispose
+- Shows a live camera feed through `ViamCameraStreamView`
+- Shows live sensor readings through `ViamSensorWidget`
+- Controls a motor through `ViamMotorWidget` with auto-stop
+- Builds and runs on every Flutter target platform from one codebase
+
+The full `lib/main.dart` is around 100 lines of code, most of which is scaffolding for the app shell rather than Viam-specific logic. The three prebuilt widgets did the heavy lifting: you wrote no stream renderer, no polling timer, no motor button wiring. That is the Flutter widget advantage.
+
+## Next steps
+
+Extend the app in one of these directions:
+
+- **Add a joystick for base control.** If your machine has a base configured, add a `ViamJoystickWidget` to drive it. The joystick widget converts user input into base movement commands.
+- **Use the multi-camera widget.** The Flutter SDK ships `ViamMultiCameraStreamView` for showing several camera feeds at once. See the [Flutter SDK reference](https://flutter.viam.dev/) for its parameters.
+- **Build a list of resources with per-resource screens.** The `viam_robot_example_app` in the Flutter SDK repo shows a pattern for enumerating the machine's resources and showing a custom screen for each. Use it as a reference for larger apps.
+- **Read from the Viam cloud.** Switch from `RobotClient.atAddress` to `Viam.withApiKey` so you can use the `appClient` and `dataClient` to enumerate machines and query captured data. See [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/).
+- **Build a multi-machine version.** See [the fleet tutorial](/build-apps/app-tutorials/tutorial-fleet/) for a dashboard that aggregates data across several machines.
diff --git a/docs/build-apps/app-tutorials/tutorial-monitoring-service.md b/docs/build-apps/app-tutorials/tutorial-monitoring-service.md
new file mode 100644
index 0000000000..461350899e
--- /dev/null
+++ b/docs/build-apps/app-tutorials/tutorial-monitoring-service.md
@@ -0,0 +1,317 @@
+---
+linkTitle: "Tutorial: Python monitoring service"
+title: "Build a Python monitoring service"
+weight: 115
+layout: "docs"
+type: "docs"
+description: "Build a Python service that connects to a Viam machine, monitors a sensor, controls a motor based on sensor readings, and shuts down cleanly."
+date: "2026-04-13"
+---
+
+In this tutorial, you will build a Python service that runs without a user interface. The finished service:
+
+- Connects to a Viam machine at startup
+- Starts a motor
+- Polls a sensor every two seconds and logs the readings
+- Stops the motor when a sensor reading exceeds a threshold
+- Shuts down cleanly when you press Ctrl+C
+
+You will learn the pattern that most headless Viam apps follow: connect, act, monitor, react, clean up. The same structure works for any long-running Python process that talks to a machine: a control loop, a data logger, a fleet monitor, or an integration service.
+
+## What you need
+
+- A configured Viam machine with a sensor and a motor. Any models work. If you do not have physical hardware, add `fake:sensor` and `fake:motor` in the Viam app's **CONFIGURE** tab.
+- A completed [Python setup](/build-apps/setup/python/). You should have a project directory with `viam-sdk` installed, a `.env` file holding your machine credentials, and a working `main.py` from the setup page.
+- A second window open to the Viam app's **CONTROL** tab for the same machine, so you can see motor state changes as they happen.
+
+Before continuing, confirm your setup by running `python main.py` and verifying that it shows `Connected. Found N resources.` If it does not, go back to [Python setup](/build-apps/setup/python/) and fix the connection before continuing.
+
+## Step 1: Connect and list resources
+
+Replace the contents of `main.py` with a connection that prints the machine's resources:
+
+```python
+import asyncio
+import os
+
+from dotenv import load_dotenv
+from viam.robot.client import RobotClient
+
+load_dotenv()
+
+
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ print(f"Connected. Resources: {[r.name for r in machine.resource_names]}")
+
+ await machine.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+Run it:
+
+```sh {class="command-line" data-prompt="$"}
+python main.py
+```
+
+You should see a list of resource names that includes your sensor and motor. If the names you see do not match the names you configured, update the constants in the next step to match.
+
+## Step 2: Get the sensor and motor, start the motor
+
+Add imports for `Sensor` and `Motor`, get them by name, and start the motor:
+
+```python
+import asyncio
+import os
+
+from dotenv import load_dotenv
+from viam.robot.client import RobotClient
+from viam.components.sensor import Sensor
+from viam.components.motor import Motor
+
+load_dotenv()
+
+SENSOR_NAME = "my_sensor"
+MOTOR_NAME = "my_motor"
+
+
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ sensor = Sensor.from_robot(robot=machine, name=SENSOR_NAME)
+ motor = Motor.from_robot(robot=machine, name=MOTOR_NAME)
+
+ await motor.set_power(power=0.5)
+ print(f"Motor '{MOTOR_NAME}' started at 50% power.")
+
+ await machine.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+Change `SENSOR_NAME` and `MOTOR_NAME` to match the names you gave these components in your machine configuration. If you used `fake:sensor` and `fake:motor`, the default names are usually `sensor` and `motor` unless you changed them.
+
+Run it. The terminal prints `Motor 'my_motor' started at 50% power.` Check the Viam app's **CONTROL** tab: the motor's power slider should show 50%. The script connects, starts the motor, and immediately closes the connection (which stops the motor through session cleanup). In the next step, you will keep the connection open.
+
+## Step 3: Poll the sensor in a loop
+
+Replace the `await machine.close()` call with a polling loop that reads the sensor every two seconds:
+
+```python
+POLL_INTERVAL = 2 # seconds
+
+
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ sensor = Sensor.from_robot(robot=machine, name=SENSOR_NAME)
+ motor = Motor.from_robot(robot=machine, name=MOTOR_NAME)
+
+ await motor.set_power(power=0.5)
+ print(f"Motor '{MOTOR_NAME}' started at 50% power.")
+
+ print(f"Monitoring sensor '{SENSOR_NAME}' every {POLL_INTERVAL}s. Press Ctrl+C to stop.")
+
+ while True:
+ readings = await sensor.get_readings()
+ print(f" {readings}")
+ await asyncio.sleep(POLL_INTERVAL)
+```
+
+Run it. The terminal prints sensor readings every two seconds. For `fake:sensor`, the output is `{'a': 1, 'b': 2, 'c': 3}` on every line. For a real sensor, the values change. The motor stays running in the background because the connection is still open.
+
+Press Ctrl+C to stop the script. The motor stops because the session ends, but the shutdown is abrupt: Python prints a traceback. The next step fixes this.
+
+## Step 4: Add graceful shutdown
+
+Wrap the polling loop in a try/finally block so the motor stops and the connection closes cleanly when you press Ctrl+C:
+
+```python
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ sensor = Sensor.from_robot(robot=machine, name=SENSOR_NAME)
+ motor = Motor.from_robot(robot=machine, name=MOTOR_NAME)
+
+ await motor.set_power(power=0.5)
+ print(f"Motor '{MOTOR_NAME}' started at 50% power.")
+
+ print(f"Monitoring sensor '{SENSOR_NAME}' every {POLL_INTERVAL}s. Press Ctrl+C to stop.")
+
+ try:
+ while True:
+ readings = await sensor.get_readings()
+ print(f" {readings}")
+ await asyncio.sleep(POLL_INTERVAL)
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ pass
+ finally:
+ print("Shutting down...")
+ await motor.stop()
+ await machine.close()
+ print("Motor stopped. Connection closed.")
+```
+
+Run it. The sensor readings print as before. Press Ctrl+C. Instead of a traceback, you see:
+
+```text
+Shutting down...
+Motor stopped. Connection closed.
+```
+
+Check the **CONTROL** tab: the motor's power slider returns to zero. The shutdown is clean: the motor is explicitly stopped, not just abandoned when the session ends.
+
+## Step 5: Add a threshold check
+
+Add logic that stops the motor when a sensor reading exceeds a threshold. This is the "reads drive writes" pattern: the service observes a condition and takes an action.
+
+Add two constants at the top of the file:
+
+```python
+READING_KEY = "a" # The sensor reading key to monitor
+THRESHOLD = 0 # Stop the motor when this value is exceeded
+```
+
+Update the polling loop to check the reading:
+
+```python
+ try:
+ while True:
+ readings = await sensor.get_readings()
+ value = readings.get(READING_KEY)
+ print(f" {READING_KEY}={value}")
+
+ if value is not None and value > THRESHOLD:
+ print(f" THRESHOLD EXCEEDED ({value} > {THRESHOLD})")
+ break
+
+ await asyncio.sleep(POLL_INTERVAL)
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ pass
+ finally:
+ print("Shutting down...")
+ await motor.stop()
+ await machine.close()
+ print("Motor stopped. Connection closed.")
+```
+
+`READING_KEY` is the key from the sensor's readings map to watch. `THRESHOLD` is the value that triggers the motor stop. For `fake:sensor`, set `READING_KEY` to `"a"` and `THRESHOLD` to `0`. Since `fake:sensor` returns `a=1`, the condition `1 > 0` is true on the first reading and the motor stops immediately. For a real sensor, set these to values that match your hardware.
+
+Run it. The output shows one reading, the threshold message, and the shutdown:
+
+```text
+Motor 'my_motor' started at 50% power.
+Monitoring sensor 'my_sensor' every 2s. Press Ctrl+C to stop.
+ a=1
+ THRESHOLD EXCEEDED (1 > 0)
+Shutting down...
+Motor stopped. Connection closed.
+```
+
+To see the monitoring loop run for longer, raise `THRESHOLD` above the sensor's maximum value. The service polls indefinitely until you press Ctrl+C or the threshold triggers.
+
+## The complete script
+
+Here is the full `main.py`:
+
+```python
+import asyncio
+import os
+
+from dotenv import load_dotenv
+from viam.robot.client import RobotClient
+from viam.components.sensor import Sensor
+from viam.components.motor import Motor
+
+load_dotenv()
+
+SENSOR_NAME = "my_sensor"
+MOTOR_NAME = "my_motor"
+READING_KEY = "a"
+THRESHOLD = 0
+POLL_INTERVAL = 2
+
+
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ sensor = Sensor.from_robot(robot=machine, name=SENSOR_NAME)
+ motor = Motor.from_robot(robot=machine, name=MOTOR_NAME)
+
+ await motor.set_power(power=0.5)
+ print(f"Motor '{MOTOR_NAME}' started at 50% power.")
+
+ print(f"Monitoring sensor '{SENSOR_NAME}' every {POLL_INTERVAL}s. Press Ctrl+C to stop.")
+
+ try:
+ while True:
+ readings = await sensor.get_readings()
+ value = readings.get(READING_KEY)
+ print(f" {READING_KEY}={value}")
+
+ if value is not None and value > THRESHOLD:
+ print(f" THRESHOLD EXCEEDED ({value} > {THRESHOLD})")
+ break
+
+ await asyncio.sleep(POLL_INTERVAL)
+ except (KeyboardInterrupt, asyncio.CancelledError):
+ pass
+ finally:
+ print("Shutting down...")
+ await motor.stop()
+ await machine.close()
+ print("Motor stopped. Connection closed.")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+The full script is 50 lines. Five constants at the top configure the behavior. One async function handles the full lifecycle: connect, start, monitor, react, clean up.
+
+## What you built
+
+You now have a Python service that:
+
+- Connects to a Viam machine using an API key from environment variables
+- Starts a motor and monitors a sensor in a polling loop
+- Stops the motor when a sensor reading exceeds a configurable threshold
+- Shuts down cleanly on Ctrl+C, explicitly stopping the motor and closing the connection
+
+This is the pattern for any headless Viam app: connect, do something, monitor something, react to conditions, clean up on exit. The specifics change (different sensors, different actions, different conditions), but the structure stays the same.
+
+## Next steps
+
+Extend the service in one of these directions:
+
+- **Monitor multiple sensors.** Get additional sensors with `Sensor.from_robot` and read from all of them in the same loop. Log each one separately.
+- **Add hysteresis.** Instead of stopping the motor permanently when the threshold is exceeded, restart it when the reading drops back below a lower threshold. This prevents rapid start-stop cycling around the boundary.
+- **Log to a file or external system.** Replace `print()` with Python's `logging` module, or send readings to a database, Prometheus, or a notification service.
+- **Run as a system service.** Deploy the script as a systemd unit so it starts on boot and restarts on failure. The graceful shutdown pattern you built in Step 4 handles `SIGTERM` from systemd the same way it handles Ctrl+C.
+- **Connect to the Viam cloud instead of one machine.** Switch from `RobotClient.at_address` to `ViamClient.create_from_dial_options` to monitor sensors across a fleet. See [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/).
diff --git a/docs/build-apps/concepts/_index.md b/docs/build-apps/concepts/_index.md
new file mode 100644
index 0000000000..7e85dae282
--- /dev/null
+++ b/docs/build-apps/concepts/_index.md
@@ -0,0 +1,11 @@
+---
+linkTitle: "Concepts"
+title: "Concepts"
+weight: 10
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/build-apps/concepts/how-apps-connect/"
+description: "Mental models for building Viam client apps: how connections work and how authentication works."
+date: "2026-04-10"
+---
diff --git a/docs/build-apps/concepts/authentication.md b/docs/build-apps/concepts/authentication.md
new file mode 100644
index 0000000000..291cea1150
--- /dev/null
+++ b/docs/build-apps/concepts/authentication.md
@@ -0,0 +1,76 @@
+---
+linkTitle: "Authentication"
+title: "Authenticating Viam client apps"
+weight: 20
+layout: "docs"
+type: "docs"
+description: "The credential types a Viam client app uses and when to use each. Covers API keys, access tokens, and hosted-app credential injection."
+date: "2026-04-10"
+---
+
+A Viam client application proves its identity to the machine or to the Viam cloud through a credential. The SDK supports two credential types: API keys for service-identity access and access tokens for user-identity access. Hosted Viam Applications inject credentials into the browser automatically through cookies.
+
+This page uses TypeScript SDK names for the specific APIs. The Flutter SDK supports the same credential types through `RobotClientOptions.withApiKey` and `Viam.withAccessToken`; see [Flutter setup](/build-apps/setup/flutter/).
+
+## API keys
+
+An API key is the default credential for a client app. You create the key in the Viam app, scope it to what it should be allowed to access, and pass it to the SDK at connection time.
+
+An API key has two pieces: an **ID** (the `authEntity` field in the SDK) and a **secret** (the `payload` field). Both are required for every connection. The secret is shown only at creation time; if you lose it, rotate the key to generate a new secret.
+
+```typescript
+const machine = await VIAM.createRobotClient({
+ host: "my-robot-main.xxxx.viam.cloud",
+ credentials: {
+ type: "api-key",
+ authEntity: process.env.API_KEY_ID,
+ payload: process.env.API_KEY,
+ },
+ signalingAddress: "https://app.viam.com:443",
+});
+```
+
+API keys have three possible scopes:
+
+| Scope | Access | When to use |
+| ------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| Machine | One machine | Your app talks to a specific machine you control, and you want the smallest blast radius if the key leaks |
+| Location | All machines in a location | Your app needs to talk to several machines that share a location, such as a warehouse deployment |
+| Organization | All machines in the organization | Your app needs broad fleet access, such as a fleet-management dashboard or a data-querying service |
+
+Create and manage API keys in [Admin and access](/organization/access/).
+
+## Access tokens
+
+An access token represents a user, not a service. Where an API key is one identity that never changes, an access token is the identity of whoever is currently logged in.
+
+Hosted Viam Applications create access tokens through the Viam OAuth flow when a user logs in. Your app code reads the token from a browser cookie and passes it to `createViamClient` to make requests scoped to what that user is allowed to see. There is no API for creating access tokens yourself; they only come from the OAuth flow.
+
+Access tokens are useful when the same app shows different data to different users, and they are the only credential type you use in a hosted Viam Application.
+
+## Hosted Viam Applications: credential injection
+
+When you deploy a web app to Viam Applications, the Viam platform handles authentication. The behavior depends on the application type you declared in `meta.json`.
+
+**Single-machine application.** Users log into your app through Viam's OAuth flow. Viam selects a machine (either the one specified in the URL or one picked through the fragment-filtered machine picker) and writes machine-specific credentials into a browser cookie: the API key, the API key ID, and the machine hostname. Your app code reads the cookie and passes the credentials to `createRobotClient`.
+
+**Multi-machine application.** Users log into your app the same way. Viam writes a user access token into a browser cookie. Your app code reads the cookie and passes the token to `createViamClient`, then uses the resulting `AppClient` to enumerate the machines the user has access to and connect to each one.
+
+In both cases, the Viam Applications platform manages the cookie lifecycle: login, token refresh, and logout all happen without your app code touching the credential directly.
+
+See [Deploy a Viam application](/build-apps/hosting/deploy/) for the deployment workflow and [the hosting platform reference](/build-apps/hosting/hosting-reference/) for the cookie format.
+
+## Where to put credentials in self-hosted apps
+
+For apps you host yourself, not on Viam Applications:
+
+- Store API keys in environment variables, not in source code.
+- In development, use a `.env` file and add `.env` to `.gitignore`.
+- In production, use your platform's secret management: Vercel environment variables, AWS Secrets Manager, Kubernetes secrets, or similar.
+- Never commit an API key to a public repository. If you do, rotate the key immediately.
+
+For apps on Viam Applications, you do not store credentials; Viam injects them through cookies at runtime.
+
+## Robot secrets
+
+Robot secrets are a legacy credential type from earlier versions of Viam. They still work for backward compatibility. Use API keys instead in new apps.
diff --git a/docs/build-apps/concepts/how-apps-connect.md b/docs/build-apps/concepts/how-apps-connect.md
new file mode 100644
index 0000000000..c829496999
--- /dev/null
+++ b/docs/build-apps/concepts/how-apps-connect.md
@@ -0,0 +1,81 @@
+---
+linkTitle: "Connection model"
+title: "How Viam client apps connect"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "The transport paths the SDK uses, the session safety mechanism, and what the SDK reconnects automatically."
+date: "2026-04-10"
+---
+
+A Viam client application needs to reach a machine that may be on a different network, behind a NAT, or both. This page describes the transport paths the SDK uses, the session safety mechanism, and how the SDK reconnects when the network drops.
+
+This page uses TypeScript SDK names for specific APIs (`createRobotClient`, `DialWebRTCConf`, `disableSessions`, and so on). The Flutter SDK provides equivalent APIs with different names; see [Flutter setup](/build-apps/setup/flutter/).
+
+## Two transports
+
+The SDK reaches a machine through one of two transports, selected by which configuration type you pass to `createRobotClient`:
+
+**WebRTC** (`DialWebRTCConf`). The default path for machines deployed on Viam Cloud. The SDK contacts a signaling service, which brokers a peer-to-peer WebRTC connection between your app and the machine. Once the connection is established, data flows directly between the two endpoints. The signaling service is only needed for the initial handshake.
+
+**Direct gRPC** (`DialDirectConf`). The SDK opens a gRPC connection directly to a host and port. Use this when you have direct network access to the machine and do not need NAT traversal, typically for local development against a machine on the same network or for an app running in the same cluster as the machine.
+
+Both transports use gRPC as the application protocol. The difference is whether the gRPC bytes travel through a WebRTC data channel (NAT-friendly, goes through signaling) or through a direct TCP/HTTP2 connection (simpler, requires direct network reachability).
+
+## WebRTC connection parameters
+
+When you use the WebRTC transport, the SDK needs two pieces of configuration in addition to the machine host:
+
+**Signaling address.** Required. The URL of the signaling service that brokers the WebRTC handshake. For machines deployed on Viam Cloud, the value is `https://app.viam.com:443`. The Viam app's CONNECT tab writes this literal string into the code sample it generates, so you copy it along with the rest of the connection code. For self-hosted or air-gapped setups, you point to a different signaling service.
+
+**ICE servers.** Optional. The SDK needs ICE servers (STUN and optionally TURN) to traverse NATs. If you do not pass `iceServers`, the SDK defaults to Twilio's public STUN server at `stun:global.stun.twilio.com:3478`. Override `iceServers` if you have custom STUN or TURN requirements for your network.
+
+Most apps never change either field. See [the connectivity reference](/reference/sdks/connectivity/) for advanced options like TURN-only relay mode, forced peer-to-peer, and custom TURN URI overrides.
+
+## Sessions
+
+A session is a client-server association that the SDK creates automatically when you connect. Sessions exist to stop actuators when a client disappears.
+
+The mechanism is a heartbeat protocol. The SDK calls `StartSession` on the machine, which returns a heartbeat window (default 2 seconds, configurable between 30 ms and 1 minute). The SDK sends heartbeats within that window. If the heartbeats stop, because your browser tab crashed, your laptop lost Wi-Fi, or your app closed without calling `close()`, the server stops any resources that are safety-monitored **and** where this session was the last caller.
+
+The practical effect: if your app called `motor.setPower(1)` and then crashed, the server stops the motor within a heartbeat window of the crash. The machine does not continue running the last command indefinitely.
+
+### Which methods are safety-monitored
+
+Not every method triggers a session stop. Only methods marked with the `safety_heartbeat_monitored` proto extension participate. The components with safety-monitored methods today:
+
+- `arm` (motion commands)
+- `base` (drive commands)
+- `button`
+- `gantry`
+- `gripper`
+- `motor` (power and position commands)
+- `servo`
+- `switch`
+
+Read-only methods like `sensor.getReadings()` or `camera.getImage()` are not safety-monitored. Reading state from a component does not trigger a session stop.
+
+### Disabling sessions
+
+Pass `disableSessions: true` in the connection options to disable session heartbeating. The session proto documentation calls this "acknowledging the safety risk": if you disable sessions, a crashed client leaves actuators in whatever state they were last commanded to. Disable sessions only for specific reasons, such as implementing your own crash-detection and cleanup logic.
+
+See [the sessions API reference](/reference/apis/sessions/) for the full protocol details.
+
+## Reconnection
+
+When the network drops, the SDK reconnects automatically with exponential backoff. The relevant options on `DialWebRTCConf` and `DialDirectConf`:
+
+- `reconnectMaxAttempts` — default 10, the number of retries before giving up
+- `reconnectMaxWait` — default `Number.POSITIVE_INFINITY`, the maximum time between retries
+- `noReconnect` — default `false`, set to `true` to disable reconnection
+
+Reconnection is transparent to your application code. The `RobotClient` object stays valid across the drop, and in-flight method calls throw errors that your app can catch and retry after the reconnection completes. While the SDK is retrying, it emits `RECONNECTING`. If retries are exhausted, it emits `RECONNECTION_FAILED`.
+
+{{< alert title="Behavior change" color="caution" >}}
+In versions of the Viam TypeScript SDK prior to v0.69.0, `DISCONNECTED` fired on any drop.
+From v0.69.0 onward, it fires only on intentional close or when `noReconnect` is set; transient drops emit `RECONNECTING`.
+{{< /alert >}}
+
+What does _not_ happen automatically is your app's UI state. If you were showing a camera stream when the network dropped, the stream stops, and your UI has to rebuild the stream when the connection returns. If you were polling a sensor, the polling stops, and your UI has to resume polling. The SDK reconnects the transport; your app reconnects its own state.
+
+Subscribe to `MachineConnectionEvent` to react to connection state changes. See [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for the pattern.
diff --git a/docs/build-apps/hosting/_index.md b/docs/build-apps/hosting/_index.md
new file mode 100644
index 0000000000..ffb53a42c2
--- /dev/null
+++ b/docs/build-apps/hosting/_index.md
@@ -0,0 +1,11 @@
+---
+linkTitle: "Viam hosting"
+title: "Viam hosting"
+weight: 150
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/build-apps/hosting/overview/"
+description: "Deploy and host a web app on Viam Applications with built-in authentication and credential injection."
+date: "2026-04-13"
+---
diff --git a/docs/build-apps/hosting/deploy.md b/docs/build-apps/hosting/deploy.md
new file mode 100644
index 0000000000..6ae32616c3
--- /dev/null
+++ b/docs/build-apps/hosting/deploy.md
@@ -0,0 +1,131 @@
+---
+linkTitle: "Deploy a Viam application"
+title: "Deploy a Viam application"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "Package your app with meta.json and upload it to Viam Applications for hosting with built-in authentication and credential injection."
+date: "2026-04-10"
+---
+
+Package your client app and upload it to Viam Applications, Viam's hosting service for web apps. Once uploaded, your app is served at `{appname}_{namespace}.viamapplications.com` with authentication and machine-credential injection handled by the platform.
+
+Viam Applications are distributed through the Viam module registry, which is why the CLI commands live under `viam module`. This has nothing to do with building server-side modules; the `module` in the command name is an artifact of the registry plumbing.
+
+## Prerequisites
+
+- A built client app. Any bundler output that produces HTML, JavaScript, and CSS works: a Vite `dist/` directory, a Create React App `build/` directory, a Flutter web build, and so on. See [TypeScript setup](/build-apps/setup/typescript/) for a minimal Vite starting point.
+- The [Viam CLI](/cli/) installed and authenticated (`viam login`).
+- A public namespace for your organization. Set this in the [organization settings](/organization/) if you have not already. Viam Applications require a public namespace because the app's URL uses it.
+
+## Create the module registry entry
+
+First-time setup only. Each Viam Application is represented as a module in the Viam registry. Create the registry entry:
+
+```sh {class="command-line" data-prompt="$"}
+viam module create --name my-app --public-namespace your-namespace
+```
+
+This creates a `meta.json` in your current directory with a `module_id` of `your-namespace:my-app` and `visibility: private`. You will change the visibility and add the `applications` field in the next step.
+
+## Configure meta.json
+
+Edit `meta.json` to declare the application. The generated file needs two changes:
+
+1. Change `visibility` from `private` to `public`. Viam Applications require public visibility.
+2. Add an `applications` array describing the app.
+
+A complete `meta.json` for a single-machine browser app:
+
+```json
+{
+ "$schema": "https://dl.viam.dev/module.schema.json",
+ "module_id": "your-namespace:my-app",
+ "visibility": "public",
+ "applications": [
+ {
+ "name": "my-app",
+ "type": "single_machine",
+ "entrypoint": "dist/index.html"
+ }
+ ]
+}
+```
+
+Key fields in the `applications` entry:
+
+- `name` — appears in the URL as `{name}_{namespace}.viamapplications.com`. Lowercase alphanumeric and hyphens only. Must be unique within your namespace.
+- `type` — `single_machine` or `multi_machine`. Single-machine apps pick one machine and inject that machine's credentials. Multi-machine apps inject a user access token and let the app enumerate machines.
+- `entrypoint` — path to the HTML entry point, relative to the uploaded archive's root. For a Vite build, this is typically `dist/index.html`.
+
+See [the meta.json applications reference](/build-apps/hosting/meta-json-reference/) for all available fields, including `fragmentIds`, `logoPath`, and `customizations`.
+
+## Build your app
+
+Run your bundler to produce the static output. For Vite:
+
+```sh {class="command-line" data-prompt="$"}
+npm run build
+```
+
+Confirm the output directory contains an `index.html` and all its asset files. The path you put in `entrypoint` must match what the build produces.
+
+## Package and upload
+
+Create a tarball of everything you want to serve, including `meta.json`:
+
+```sh {class="command-line" data-prompt="$"}
+tar -czvf module.tar.gz meta.json dist/
+```
+
+Adjust the tar command to include whatever directories your build produced. The `meta.json` must be at the root of the archive.
+
+Upload to the registry:
+
+```sh {class="command-line" data-prompt="$"}
+viam module upload --upload module.tar.gz --platform any --version 0.0.1
+```
+
+Use `--platform any` for Viam Applications. Static web apps are platform-independent; the `any` platform tag tells the registry to serve the same archive to every user regardless of operating system.
+
+The `--version` flag is required and must be a semver string. You cannot reuse a version number, so each upload needs a higher version than the last.
+
+## Verify the deployment
+
+Your app is now live at:
+
+```text
+https://{name}_{namespace}.viamapplications.com
+```
+
+Replace `{name}` with the `name` field from your `meta.json` and `{namespace}` with your organization's public namespace. Open the URL in a browser. You should see:
+
+1. A redirect to the Viam login flow.
+2. After login, for a single-machine app: the machine picker (if the app has no fragment filter) or the app itself (if the URL targets a specific machine).
+3. For a multi-machine app: your app loads directly with the user access token already in cookies.
+
+If the app does not load, check:
+
+- **The `applications` array in the uploaded `meta.json`**. If the registry sees no applications, no URL is assigned. Re-upload with the correct field after fixing `meta.json`.
+- **The `entrypoint` path.** The path is relative to the archive root. If `meta.json` is at the root and your HTML is at `dist/index.html`, the entrypoint should be `dist/index.html`, not `/dist/index.html` or `index.html`.
+- **The namespace.** The URL uses your organization's public namespace, not the organization name or ID. Confirm the public namespace in the [organization settings](/organization/).
+
+## Release updates
+
+To release a new version of your app, rebuild and re-upload with a higher version number:
+
+```sh {class="command-line" data-prompt="$"}
+npm run build
+tar -czvf module.tar.gz meta.json dist/
+viam module upload --upload module.tar.gz --platform any --version 0.0.2
+```
+
+Viam Applications always serve the latest uploaded version. There is no staging step and no version selection in the URL.
+
+To roll back, upload the previous code under a new, higher version number. The registry rejects duplicate version numbers, so you cannot reuse an older one.
+
+## Next
+
+- [meta.json applications reference](/build-apps/hosting/meta-json-reference/) for the full schema
+- [Hosting platform reference](/build-apps/hosting/hosting-reference/) for URL patterns, cookie structure, caching behavior, and limits
+- [Test against a local machine](/build-apps/tasks/test-locally/) for iterating on your app before each upload
diff --git a/docs/build-apps/hosting/hosting-reference.md b/docs/build-apps/hosting/hosting-reference.md
new file mode 100644
index 0000000000..f62cfecc8a
--- /dev/null
+++ b/docs/build-apps/hosting/hosting-reference.md
@@ -0,0 +1,162 @@
+---
+linkTitle: "Hosting reference"
+title: "Viam Applications hosting platform reference"
+weight: 30
+layout: "docs"
+type: "docs"
+description: "URL patterns, cookie structure, caching behavior, and limits for the Viam Applications hosting platform."
+date: "2026-04-10"
+---
+
+Reference for the runtime behavior of the Viam Applications hosting platform. For the deployment workflow, see [Deploy a Viam application](/build-apps/hosting/deploy/). For the `meta.json` schema that configures the application, see [the meta.json applications reference](/build-apps/hosting/meta-json-reference/).
+
+## URL pattern
+
+Every Viam Application is served at:
+
+```text
+https://{name}_{namespace}.viamapplications.com
+```
+
+- `{name}` is the `name` field from the application's `meta.json` entry.
+- `{namespace}` is the public namespace of the organization that owns the module.
+
+Example: an application named `dashboard` in the `acme` namespace is served at `https://dashboard_acme.viamapplications.com`.
+
+Internal Viam environments use `viamapps.dev` instead of `viamapplications.com`, but the `{name}_{namespace}` pattern is the same.
+
+## Authentication flow
+
+All Viam Applications require the user to be logged into Viam. The first visit to an application's URL redirects through Viam's OAuth flow; after login, the user lands on the application with credentials in browser cookies.
+
+For single-machine applications, the flow includes a machine selection step if the application has `fragmentIds` that match more than one machine in the user's fleet, or if the user accesses the base URL without a specific machine in the path.
+
+## Cookies
+
+Viam injects credentials into browser cookies after login. Your application code reads the cookies and passes the credentials to the SDK.
+
+### Single-machine applications
+
+A single-machine application receives one cookie per machine the user has selected. The cookie name is the machine ID, and its value is a JSON object with the following shape:
+
+```json
+{
+ "apiKey": {
+ "id": "api-key-id",
+ "key": "api-key-secret"
+ },
+ "id": "api-key-id",
+ "key": "api-key-secret",
+ "credentials": {
+ "type": "api-key",
+ "payload": "api-key-secret",
+ "authEntity": "api-key-id"
+ },
+ "hostname": "machine-main.xxxx.viam.cloud",
+ "machineId": "machine-uuid",
+ "timestamp": 1712620800
+}
+```
+
+The `credentials` object is structured for direct use with `createRobotClient`:
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+import Cookies from "js-cookie";
+
+const pathParts = window.location.pathname.split("/");
+const machineId = pathParts[2]; // from URL like /machine/{id}/...
+
+const cookieValue = JSON.parse(Cookies.get(machineId)!);
+
+const machine = await VIAM.createRobotClient({
+ host: cookieValue.hostname,
+ credentials: cookieValue.credentials,
+ signalingAddress: "https://app.viam.com:443",
+});
+```
+
+The `id` and `key` top-level fields are duplicates of `apiKey.id` and `apiKey.key` retained for backwards compatibility. New code should use either `apiKey` or `credentials` directly.
+
+### Multi-machine applications
+
+A multi-machine application receives one cookie named `userToken` containing the logged-in user's OAuth access token. The cookie value is a JSON object representing the full `oauth2.Token`:
+
+```json
+{
+ "access_token": "...",
+ "token_type": "Bearer",
+ "refresh_token": "...",
+ "expiry": "2026-04-10T12:00:00Z"
+}
+```
+
+Read the `access_token` field and pass it to `createViamClient` as an access-token credential:
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+import Cookies from "js-cookie";
+
+const userTokenRaw = Cookies.get("userToken")!;
+const { access_token } = JSON.parse(userTokenRaw);
+
+const client = await VIAM.createViamClient({
+ credentials: {
+ type: "access-token",
+ payload: access_token,
+ },
+});
+```
+
+Use the resulting `ViamClient` to enumerate and connect to specific machines. See [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/).
+
+## Cookie lifecycle
+
+Machine cookies in single-machine applications have a 30-day maximum age. The platform refreshes them automatically when the user visits the application after 75% of the maximum age has elapsed (approximately 22.5 days). Users who return within the refresh window stay logged in without re-authenticating.
+
+User tokens in multi-machine applications follow the underlying OAuth token lifecycle: the platform refreshes access tokens through the standard OAuth refresh flow without user interaction. The `userToken` cookie is updated with the new access token when it is refreshed.
+
+## Caching
+
+The hosting platform caches your app's files with the following `Cache-Control` behavior:
+
+| Content type | Cache-Control header | Behavior |
+| ------------------------------------------------ | ------------------------------------- | --------------------------------------------------------------------------- |
+| HTML files (entrypoint and any `.html`) | `no-cache, no-store, must-revalidate` | Never cached in browser or CDN. Every request hits the latest version. |
+| All other static assets (CSS, JS, images, fonts) | `private, max-age=86400` | Cached in the user's browser for 24 hours. Not cached in shared CDN layers. |
+
+The server also maintains a short-lived in-memory cache (approximately 5 minutes) of each application's blob path and entrypoint lookup, to avoid hitting the module registry database on every request. This cache invalidates automatically within a few minutes of an upload, so new versions go live within minutes of `viam module upload` completing.
+
+If you see stale JavaScript or CSS after an upload, the cause is almost always your browser cache, not the platform cache. Hard-reload the page (Ctrl+Shift+R or Cmd+Shift+R) or open the application in an incognito window.
+
+## Storage and delivery
+
+Uploaded application tarballs are extracted to Google Cloud Storage under a path of the form `{namespace}/{app-name}/{version}/`. The hosting server proxies user requests to GCS through an authenticated reverse proxy. The proxy:
+
+- Strips the `/machine/{machineId}/` prefix from the URL before forwarding, for single-machine applications.
+- Injects a ` ` tag into HTML responses so relative asset paths resolve correctly under the prefix-stripped path.
+- Sets the cache headers described above.
+- Sets machine or user cookies on the response where applicable.
+
+Application authors do not interact with GCS directly; all uploads go through `viam module upload`.
+
+## Versioning
+
+Viam Applications always serve the latest uploaded version for a given `name` + namespace combination. There is no URL parameter, header, or manifest field for selecting a specific version.
+
+To release a new version, upload with a higher semver `--version`. The registry rejects duplicate version numbers.
+
+To roll back, upload the previous code under a new, higher version number. There is no direct "revert" operation.
+
+## Limits
+
+- **Public visibility required.** The module hosting the application must have `visibility: public` in `meta.json`. Private modules cannot host applications.
+- **Static files only.** Viam Applications does not execute server-side code. No serverless functions, no backend endpoints, no API routes. The hosted content is HTML, JS, CSS, images, and other static assets only. Your browser-side application logic can be arbitrarily dynamic.
+- **Cookies required.** Users whose browsers block cookies cannot log into Viam Applications.
+- **One application per URL.** Each `name`+namespace combination serves a single application. Multiple applications with the same name in the same namespace are not allowed.
+
+## Related
+
+- [Deploy a Viam application](/build-apps/hosting/deploy/) for the package-and-upload workflow
+- [meta.json applications schema](/build-apps/hosting/meta-json-reference/) for the application configuration
+- [Authentication](/build-apps/concepts/authentication/) for the credential injection model
diff --git a/docs/build-apps/hosting/meta-json-reference.md b/docs/build-apps/hosting/meta-json-reference.md
new file mode 100644
index 0000000000..76b7f53186
--- /dev/null
+++ b/docs/build-apps/hosting/meta-json-reference.md
@@ -0,0 +1,159 @@
+---
+linkTitle: "meta.json schema"
+title: "meta.json applications schema"
+weight: 20
+layout: "docs"
+type: "docs"
+description: "Reference for the applications array in meta.json, which declares one or more Viam Applications for hosted deployment."
+date: "2026-04-10"
+---
+
+Reference for the `applications` array in `meta.json`. This array declares one or more Viam Applications that Viam hosts at `*.viamapplications.com` after you upload the module. For the deployment workflow that produces and uploads `meta.json`, see [Deploy a Viam application](/build-apps/hosting/deploy/).
+
+## Complete example
+
+```json
+{
+ "$schema": "https://dl.viam.dev/module.schema.json",
+ "module_id": "acme:dashboard",
+ "visibility": "public",
+ "applications": [
+ {
+ "name": "dashboard",
+ "type": "multi_machine",
+ "entrypoint": "dist/index.html",
+ "fragmentIds": ["f0b5a1c2-..."],
+ "allowedOrgIds": ["org-id-1", "org-id-2"],
+ "logoPath": "static/logo.png",
+ "customizations": {
+ "machinePicker": {
+ "heading": "Select your device",
+ "subheading": "Choose a machine to monitor"
+ }
+ }
+ }
+ ]
+}
+```
+
+## Top-level module fields
+
+These fields apply to the whole module, not to a specific application. They are required even when the module's only purpose is to host an application.
+
+| Field | Type | Required | Notes |
+| -------------- | ------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
+| `$schema` | string | Optional | Canonical value: `"https://dl.viam.dev/module.schema.json"`. Enables editor schema validation. |
+| `module_id` | string | Required | Format: `:`. Generated by `viam module create`. |
+| `visibility` | string | Required | Must be `"public"` for modules with applications. Private modules cannot host Viam Applications. |
+| `applications` | array | Optional at the module level, but required if the module has any application content | See the application object fields below. |
+
+## Application object fields
+
+Each element of the `applications` array is an application object with the fields below.
+
+### `name`
+
+Type: `string`. Required.
+
+The application's URL segment. Appears in the URL as `{name}_{namespace}.viamapplications.com`, where `{namespace}` is your organization's public namespace.
+
+Constraints:
+
+- Lowercase alphanumeric characters and hyphens only.
+- Cannot start or end with a hyphen.
+- Must be unique within your organization's namespace.
+
+Example: `"dashboard"` produces `dashboard_acme.viamapplications.com` (if the namespace is `acme`).
+
+### `type`
+
+Type: `string`. Required.
+
+Declares how the application accesses machines. Two valid values:
+
+| Value | Behavior |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `"single_machine"` | The application operates on one machine at a time. Viam injects that machine's API key and hostname into browser cookies. |
+| `"multi_machine"` | The application can access any machine the logged-in user has permissions for. Viam injects the user's access token into a browser cookie. |
+
+See [Authentication](/build-apps/concepts/authentication/) for what gets injected and how your app code reads the cookies.
+
+### `entrypoint`
+
+Type: `string`. Required.
+
+Path to the HTML entry point, relative to the root of the uploaded archive. This is where the hosting platform's proxy looks for the first HTML file to serve.
+
+Example: `"dist/index.html"` for a Vite build that outputs to `dist/`.
+
+The path must match what your build produces. If your bundler writes `build/index.html` instead of `dist/index.html`, use `"build/index.html"`.
+
+### `fragmentIds`
+
+Type: `array of string`. Optional. Single-machine applications only.
+
+List of fragment IDs that restrict which machines appear in the machine picker. A machine is selectable only if its configuration includes every fragment in this list. Use this to scope a single-machine application to machines provisioned for a specific customer or device model.
+
+If the array is empty or omitted, any machine the user has access to is selectable.
+
+### `allowedOrgIds`
+
+Type: `array of string`. Optional.
+
+List of organization IDs that are allowed to use this application. If set, users who belong to organizations not in this list cannot access the application even if it is public.
+
+If the array is empty or omitted, any organization with access to the application can use it.
+
+### `logoPath`
+
+Type: `string`. Optional. Single-machine applications only.
+
+Path to a logo image to display on the machine picker screen. Two forms are accepted:
+
+- A path relative to the uploaded archive root, such as `"static/logo.png"`.
+- An absolute URL, such as `"https://example.com/logo.png"`.
+
+Relative paths are served from the application's own archive; absolute URLs are loaded from the remote host you specify.
+
+### `customizations`
+
+Type: `object`. Optional. Currently applies to the machine picker screen only.
+
+Customizes user-facing text on Viam-rendered screens (the machine picker for single-machine applications, and potentially other screens in the future). Currently one subfield:
+
+#### `customizations.machinePicker`
+
+Type: `object`. Optional.
+
+| Field | Type | Max length | Notes |
+| ------------ | ------ | -------------- | ----------------------------------------- |
+| `heading` | string | 60 characters | Large title on the machine picker screen. |
+| `subheading` | string | 256 characters | Supporting text below the heading. |
+
+Example:
+
+```json
+"customizations": {
+ "machinePicker": {
+ "heading": "ACME Warehouse Dashboard",
+ "subheading": "Select a warehouse rover to monitor and control."
+ }
+}
+```
+
+## Field summary
+
+| Field | Type | Required | Single-machine only | Notes |
+| ---------------- | --------------- | -------- | ------------------- | ----------------------------------------------- |
+| `name` | string | Yes | — | URL segment, lowercase alphanumeric and hyphens |
+| `type` | string | Yes | — | `"single_machine"` or `"multi_machine"` |
+| `entrypoint` | string | Yes | — | Relative path to HTML file |
+| `fragmentIds` | array of string | No | Yes | Filter machines by fragment |
+| `allowedOrgIds` | array of string | No | — | Restrict access to specific orgs |
+| `logoPath` | string | No | Yes | Path or URL for machine picker logo |
+| `customizations` | object | No | — | Custom headings for Viam-rendered screens |
+
+## Related
+
+- [Deploy a Viam application](/build-apps/hosting/deploy/) for the package-and-upload workflow
+- [Hosting platform reference](/build-apps/hosting/hosting-reference/) for URL patterns, cookie structure, caching, and limits
diff --git a/docs/build-apps/hosting/overview.md b/docs/build-apps/hosting/overview.md
new file mode 100644
index 0000000000..3f88b66ff9
--- /dev/null
+++ b/docs/build-apps/hosting/overview.md
@@ -0,0 +1,39 @@
+---
+linkTitle: "Overview"
+title: "Viam hosting"
+weight: 1
+layout: "docs"
+type: "docs"
+description: "Host a browser-based Viam app on Viam Applications with built-in authentication, credential injection, and a dedicated URL."
+date: "2026-04-13"
+---
+
+Viam Applications is a hosting service for browser-based apps built with the TypeScript SDK or any frontend framework that produces HTML, JavaScript, and CSS. You upload your app's build output to the Viam registry, and Viam serves it at `{name}_{namespace}.viamapplications.com`.
+
+Viam Applications is one of five [deployment options](/build-apps/overview/#deployment-options) for SDK-based apps. It works by serving your app's files (HTML, JavaScript, CSS) to a web browser. The browser downloads the files and runs the JavaScript locally. This means Viam Applications works for apps built with TypeScript, React, Vue, Svelte, or any other framework that compiles to files a browser can run. It does not work for Python, Go, or C++ apps, which run as processes on a server or workstation and need their own hosting; see the long-running service and local execution rows in the [deployment options table](/build-apps/overview/#deployment-options).
+
+Viam Applications handles three things that you would otherwise build yourself:
+
+- **Authentication.** Users log in through Viam's OAuth flow. You do not implement a login page, manage tokens, or integrate an identity provider.
+- **Credential delivery.** After login, Viam stores an authentication credential in a browser cookie that your app reads to connect to machines. For single-machine apps, this is a machine-scoped API key. For multi-machine apps, this is the user's OAuth access token. The cookie is scoped to the app's domain and set with `SameSite=Strict`.
+- **Hosting and TLS.** Viam serves your app over HTTPS at a dedicated URL. You do not configure a web server, provision a certificate, or manage a CDN.
+
+## Two application types
+
+Viam Applications supports two types, declared in your `meta.json`:
+
+**Single-machine.** The app operates on one machine at a time. After login, Viam presents a machine picker (optionally filtered by fragment) and delivers a machine-scoped API key and hostname to the browser. Your app reads the credential from the cookie and passes it to the SDK. Use this when your app is a control interface or dashboard for a specific device.
+
+**Multi-machine.** The app can access any machine the logged-in user has permissions for. After login, Viam delivers the user's OAuth access token to the browser. Your app reads the token from the cookie and passes it to the SDK to enumerate machines across the organization and connect to each one. Use this when your app is a fleet dashboard or a multi-device management tool.
+
+## What's in this section
+
+- [Deploy a Viam application](/build-apps/hosting/deploy/) walks through packaging your app, configuring `meta.json`, and uploading to the registry.
+- [meta.json applications schema](/build-apps/hosting/meta-json-reference/) is the field-by-field reference for the `applications` array that declares your app's type, entrypoint, fragment filters, and branding.
+- [Hosting platform reference](/build-apps/hosting/hosting-reference/) documents the URL pattern, cookie structure, caching behavior, and limits.
+
+## Constraints
+
+- Viam Applications hosts browser-based apps only. It serves your files from storage and does not execute any server-side code: no serverless functions, no backend endpoints, no API routes.
+- Viam always serves the latest uploaded version. There is no version selection or rollback UI; to roll back, upload the previous code under a new version number.
+- Cookies are required. Browsers with cookies disabled cannot use Viam Applications.
diff --git a/docs/build-apps/overview.md b/docs/build-apps/overview.md
new file mode 100644
index 0000000000..596dab0cc3
--- /dev/null
+++ b/docs/build-apps/overview.md
@@ -0,0 +1,71 @@
+---
+linkTitle: "Overview"
+title: "Build apps"
+weight: 1
+layout: "docs"
+type: "docs"
+description: "Build software that uses a Viam SDK to talk to your machines and the Viam cloud, from web dashboards to long-running backend services."
+date: "2026-04-10"
+---
+
+A Viam app is software that uses a Viam SDK to talk to a machine or to the Viam cloud. It runs outside `viam-server`: in a browser, on a phone, on a server, or on a laptop. Viam apps come in many shapes: a browser dashboard, a Flutter app on a kiosk, a Python service that polls sensors, a Go program that orchestrates a fleet.
+
+The line between an app and a [module](/build-modules/) is where the code runs. A module runs inside `viam-server` and extends it with new components or services. An app runs outside `viam-server` and uses the SDK to talk to it.
+
+This section covers building apps: setup for each language, connection and authentication, streaming video, querying captured data, controlling components, and deployment.
+
+## What the SDK does
+
+The Viam SDKs handle the connection details for you. They open the transport (WebRTC where appropriate, gRPC otherwise), manage sessions for safety, and reconnect automatically when the network drops. You write business logic; the SDK handles the wire.
+
+## Deployment options
+
+A Viam app can be distributed and run in five different ways. Most apps fit cleanly into one of these shapes; a few combine two (a long-running service plus a web frontend, for example).
+
+| Deployment | What it is | Typical platform |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| **Viam Applications** | A web app hosted by Viam at `{name}_{namespace}.viamapplications.com`. Auth and machine credentials are injected automatically. No server-side code execution. Always serves the latest uploaded version. | Browser (TypeScript or any frontend framework) |
+| **Self-hosted web app** | A web app you serve yourself: Vercel, Netlify, S3, GitHub Pages, your own server. You handle TLS, auth, credentials, and the domain. You get full control of versioning, server-side functions, and anything else your hosting platform supports. | Browser (TypeScript or any frontend framework) |
+| **Distributed binary** | A native app you build and ship through an app store, an installer, or an internal channel. The user installs it on their device and opens it from their home screen or desktop. | Flutter (iOS, Android, Linux, macOS, web, Windows) or React Native (iOS, Android) |
+| **Long-running service** | A daemon that runs continuously on a server, container, or edge device. No UI; it talks to one or more machines, processes data, triggers actions, exposes its own API, or integrates with other systems. Operated by ops tooling (systemd, Kubernetes, supervisord) rather than by a user clicking on something. | Python, Go, Node.js, C++ |
+| **Local execution** | Code you run from your own laptop or terminal: a script, a notebook, a Flutter desktop build for development. Useful for prototyping, ad-hoc operations, debugging, or scripts you run by hand. | TypeScript, Python, Go, Flutter, Node.js, React Native, C++ |
+
+The platform column is a guide, not a constraint. Most platforms fit several deployment shapes (a Flutter app can be distributed as a binary or run locally for development; TypeScript can run in a browser or as a Node service).
+
+## Viam hosting
+
+If you are building a browser-based app, Viam can host it for you. Viam Applications serves your app at a dedicated URL with authentication and credential injection handled by the platform. See [Viam hosting](/build-apps/hosting/) for how it works, how to deploy, and the hosting platform details.
+
+## Where to start
+
+If you want **a quick operator interface and don't need to write code**, use [teleop workspaces](/monitor/teleop-workspaces/). They are widget-based and live in the monitor section.
+
+If you want **a custom web dashboard or operator interface in a browser**, see [TypeScript setup](/build-apps/setup/typescript/) and the [single-machine dashboard tutorial](/build-apps/app-tutorials/tutorial-dashboard/).
+
+If you want **one app that runs on phones, tablets, and desktops from a single codebase**, see [Flutter setup](/build-apps/setup/flutter/) and the [Flutter widget tutorial](/build-apps/app-tutorials/tutorial-flutter-app/).
+
+If you want **a long-running service or script that talks to Viam without a UI**, pick your language: [Python](/build-apps/setup/python/), [Go](/build-apps/setup/go/), [Node.js](/build-apps/setup/node/), or [C++](/build-apps/setup/cpp/). The [Python monitoring service tutorial](/build-apps/app-tutorials/tutorial-monitoring-service/) walks through building one from scratch.
+
+If you want **a dashboard that aggregates data across many machines**, see [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/) and the [multi-machine fleet dashboard tutorial](/build-apps/app-tutorials/tutorial-fleet/).
+
+If you want **Viam to host your web app**, see [Hosting](/build-apps/hosting/) and [Deploy a Viam application](/build-apps/hosting/deploy/).
+
+If you are **coming from Foxglove, rosbridge, or a custom WebRTC stack**, read [How apps connect](/build-apps/concepts/how-apps-connect/) to understand Viam's connection model, then check the limits below.
+
+## What build-apps does not cover
+
+Some things that sound related live elsewhere or are not currently supported.
+
+- **3D scene visualization inside a custom app.** Viam has a built-in 3D Scene tab on the machine page, but the SDKs do not expose 3D rendering primitives. If you need 3D in a custom app, bring your own library (three.js, react-three-fiber, Unity, Babylon) and plot Viam data into it.
+- **Alarm aggregation for fleet dashboards.** Viam has alerts in the [monitor section](/monitor/alert/), but the SDKs do not provide alarm aggregation, deduplication, or severity ranking. Build that yourself.
+- **Foxglove interop.** Viam does not support MCAP log import, Foxglove layout import, or embedding Foxglove panels.
+- **Server-side code execution on Viam Applications.** Viam Applications serves your app's files from storage and does not execute any of your code server-side. No serverless functions, no backend endpoints, no API routes. Your app can be as dynamic and interactive as you want in the browser; Viam just does not run server-side logic on your behalf. For that, host a backend service yourself (the long-running service deployment shape above).
+- **Version pinning and rollback for hosted apps.** Viam Applications always serves the latest uploaded version. To roll back, upload the previous code under a new version number.
+- **Modules.** Code that runs _inside_ `viam-server` to extend it with new component types, services, or logic is a module, not an app. See [Build and deploy modules](/build-modules/) for that path.
+
+## See also
+
+- [Teleop workspaces](/monitor/teleop-workspaces/) for no-code custom control interfaces built from widgets
+- [SDK reference](/reference/sdks/) for TypeScript, Flutter, Python, Go, and C++ API documentation
+- [Connectivity reference](/reference/sdks/connectivity/) for sessions, WebRTC behavior, and local-network connections
+- [Admin and access](/organization/) for creating API keys and managing organization access
diff --git a/docs/build-apps/setup/_index.md b/docs/build-apps/setup/_index.md
new file mode 100644
index 0000000000..9421a3ed66
--- /dev/null
+++ b/docs/build-apps/setup/_index.md
@@ -0,0 +1,11 @@
+---
+linkTitle: "App scaffolding"
+title: "App scaffolding"
+weight: 20
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/build-apps/setup/overview/"
+description: "Scaffold a Viam app project in any supported language: create a project, install the SDK, configure credentials, and verify the connection."
+date: "2026-04-13"
+---
diff --git a/docs/build-apps/setup/cpp.md b/docs/build-apps/setup/cpp.md
new file mode 100644
index 0000000000..abfcc46657
--- /dev/null
+++ b/docs/build-apps/setup/cpp.md
@@ -0,0 +1,156 @@
+---
+linkTitle: "C++ setup"
+title: "C++ setup"
+weight: 70
+layout: "docs"
+type: "docs"
+description: "Set up a project for writing a Viam app in C++: an embedded application, a high-performance service, or any other C++ program that talks to a Viam machine."
+date: "2026-04-13"
+---
+
+Set up a project for writing a Viam app in C++: an embedded application, a high-performance service, or any other C++ program that talks to a Viam machine. The C++ SDK has a heavier build setup than the other SDKs because it requires CMake and several system-level dependencies. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- CMake 3.25 or later
+- A C++ compiler with C++17 support
+- The following system libraries, installed through your package manager:
+
+ **Linux (apt):**
+
+ ```sh {class="command-line" data-prompt="$"}
+ sudo apt-get install cmake build-essential libboost-all-dev libgrpc++-dev libprotobuf-dev libxtensor-dev pkg-config ninja-build
+ ```
+
+ **macOS (Homebrew):**
+
+ ```sh {class="command-line" data-prompt="$"}
+ brew install cmake boost grpc protobuf xtensor pkg-config ninja buf
+ ```
+
+- A configured Viam machine
+- The machine's URI (address), an API key ID, and an API key
+
+Get the three credentials from the machine's **CONNECT** tab in the Viam app: go to the machine's page, click **CONNECT**, select **C++**, and toggle **Include API key** on.
+
+## Build or install the SDK
+
+The C++ SDK can be built from source or consumed through the [Conan package manager](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/doc/conan.md). Building from source:
+
+```sh {class="command-line" data-prompt="$"}
+git clone https://github.com/viamrobotics/viam-cpp-sdk.git
+cd viam-cpp-sdk
+mkdir build
+cd build
+cmake .. -G Ninja
+ninja
+sudo ninja install
+```
+
+See the SDK's [BUILDING.md](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/doc/BUILDING.md) for the full set of CMake options, Conan instructions, and Docker-based development workflows.
+
+## Create a project
+
+Create a directory for your app:
+
+```sh {class="command-line" data-prompt="$"}
+mkdir my-viam-app
+cd my-viam-app
+```
+
+Create a `CMakeLists.txt`:
+
+```cmake
+cmake_minimum_required(VERSION 3.25)
+project(my-viam-app)
+
+find_package(viam-cpp-sdk REQUIRED)
+
+add_executable(my-viam-app main.cpp)
+target_link_libraries(my-viam-app PRIVATE viam-cpp-sdk::viamsdk)
+```
+
+## Verify the connection
+
+Create `main.cpp`:
+
+```cpp
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+using namespace viam::sdk;
+
+int main() {
+ Instance inst;
+
+ const char* uri = std::getenv("MACHINE_ADDRESS");
+ const char* key_id = std::getenv("API_KEY_ID");
+ const char* key = std::getenv("API_KEY");
+
+ if (!uri || !key_id || !key) {
+ std::cerr << "Set MACHINE_ADDRESS, API_KEY_ID, and API_KEY\n";
+ return 1;
+ }
+
+ ViamChannel::Options channel_options;
+ channel_options.set_entity(std::string(key_id));
+ Credentials credentials("api-key", std::string(key));
+ channel_options.set_credentials(credentials);
+ Options options(1, channel_options);
+
+ auto robot = RobotClient::at_address(std::string(uri), options);
+
+ std::vector resource_names = robot->resource_names();
+ std::cout << "Connected. Found " << resource_names.size()
+ << " resources." << std::endl;
+
+ return 0;
+}
+```
+
+Build and run:
+
+```sh {class="command-line" data-prompt="$"}
+mkdir build
+cd build
+cmake ..
+make
+MACHINE_ADDRESS=my-robot-main.xxxx.viam.cloud \
+ API_KEY_ID=your-api-key-id \
+ API_KEY=your-api-key-secret \
+ ./my-viam-app
+```
+
+You should see:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+The `Instance` object must be created before any other SDK objects and must outlive them all. It initializes the SDK's internal state. Creating it at the top of `main()` and letting it go out of scope at the end is the standard pattern.
+
+## WebRTC note
+
+The C++ SDK's WebRTC support is implemented through a Rust FFI layer. The SDK's README notes that this implementation is still maturing and may have issues with streaming requests. If you encounter problems with WebRTC connections, disable WebRTC to fall back to direct gRPC. See the [SDK's README](https://github.com/viamrobotics/viam-cpp-sdk#a-note-on-connectivity-and-webrtc-functionality) for the current status.
+
+## Troubleshooting
+
+- **`find_package(viam-cpp-sdk)` fails.** The SDK is not installed or not on CMake's search path. Rebuild and install the SDK (`sudo ninja install`), or set `CMAKE_PREFIX_PATH` to where you installed it.
+- **Linker errors referencing Boost, gRPC, or protobuf.** One of the system dependencies is missing or is an incompatible version. Reinstall through your package manager and confirm versions meet the minimums.
+- **Connection hangs.** Verify the machine address matches the **CONNECT** tab exactly. If WebRTC is the issue, try disabling it to isolate the transport layer from your application logic.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and status indicators
+- [C++ SDK reference](https://cpp.viam.dev/) for per-component API details
+- [BUILDING.md](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/doc/BUILDING.md) for the full build system documentation
diff --git a/docs/build-apps/setup/flutter.md b/docs/build-apps/setup/flutter.md
new file mode 100644
index 0000000000..f2853d4a3b
--- /dev/null
+++ b/docs/build-apps/setup/flutter.md
@@ -0,0 +1,204 @@
+---
+linkTitle: "Flutter setup"
+title: "Flutter setup"
+weight: 30
+layout: "docs"
+type: "docs"
+description: "Set up a project for building a Viam app that runs across iOS, Android, and desktop platforms from a single codebase."
+date: "2026-04-10"
+---
+
+Set up a project for building a Viam app that runs across iOS, Android, and desktop platforms from a single codebase: a tablet operator interface, a warehouse kiosk, a phone app for technicians in the field. This page covers the project scaffolding with [Flutter](https://flutter.dev/), the Viam Flutter SDK install, and the iOS/macOS and Android platform configuration. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- Flutter SDK 3.0.0 or later ([install Flutter](https://docs.flutter.dev/get-started/install))
+- Platform tooling for at least one of your targets:
+ - **iOS:** Xcode and an Apple Developer account (or the iOS Simulator)
+ - **Android:** Android Studio with Android SDK 23 or later and Kotlin `1.8.20` or later
+ - **Linux, macOS, Windows:** the desktop tooling for your target ([Flutter desktop setup](https://docs.flutter.dev/platform-integration/desktop))
+ - **Web:** a Chromium-based browser for development ([Flutter web setup](https://docs.flutter.dev/platform-integration/web))
+- A configured Viam machine
+- The machine's address, an API key, and an API key ID
+
+Get the credentials from the machine's **CONNECT** tab in the Viam app: click **CONNECT**, select **Flutter**, and copy the address, API key ID, and API key from the generated code sample.
+
+## Create a project
+
+```sh {class="command-line" data-prompt="$"}
+flutter create my_viam_app
+cd my_viam_app
+```
+
+`flutter create` generates a project that targets all six Flutter platforms. To narrow the targets, pass `--platforms=ios,android` (or any subset) to the command.
+
+## Add the Viam SDK
+
+```sh {class="command-line" data-prompt="$"}
+flutter pub add viam_sdk flutter_dotenv
+```
+
+`flutter_dotenv` loads environment variables from a `.env` file at runtime, which keeps credentials out of source code.
+
+## Configure iOS and macOS (if targeting Apple platforms)
+
+The Viam SDK uses WebRTC and mDNS, both of which require explicit permissions on iOS and macOS.
+
+Add the following to `ios/Runner/Info.plist` and `macos/Runner/Info.plist`, inside the top-level ``:
+
+```xml
+NSLocalNetworkUsageDescription
+This app needs access to the local network to connect to your Viam machine.
+NSBonjourServices
+
+ _rpc._tcp
+
+```
+
+Set the minimum iOS deployment target to 13.0 in `ios/Podfile`:
+
+```ruby
+platform :ios, '13.0'
+```
+
+## Configure Android (if targeting Android)
+
+In `android/app/build.gradle`, confirm that the minimum SDK version is 23 or higher:
+
+```groovy
+defaultConfig {
+ minSdkVersion 23
+ // ...
+}
+```
+
+If your Kotlin version is lower than `1.8.20`, bump it in `android/build.gradle`:
+
+```groovy
+ext.kotlin_version = '1.8.20'
+```
+
+## Configure environment variables
+
+Create a `.env` file in your project root:
+
+```text
+MACHINE_ADDRESS=my-robot-main.xxxx.viam.cloud
+API_KEY_ID=your-api-key-id
+API_KEY=your-api-key-secret
+```
+
+Replace the three values with what you copied from the **CONNECT** tab.
+
+Add `.env` to your `.gitignore`:
+
+```sh {class="command-line" data-prompt="$"}
+echo ".env" >> .gitignore
+```
+
+Register `.env` as an asset in `pubspec.yaml` so Flutter bundles it with the app:
+
+```yaml
+flutter:
+ assets:
+ - .env
+```
+
+## Verify the connection
+
+Replace the contents of `lib/main.dart` with:
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:flutter_dotenv/flutter_dotenv.dart';
+import 'package:viam_sdk/viam_sdk.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+ await dotenv.load();
+ runApp(const MyApp());
+}
+
+class MyApp extends StatelessWidget {
+ const MyApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const MaterialApp(
+ home: Scaffold(body: Center(child: StatusText())),
+ );
+ }
+}
+
+class StatusText extends StatefulWidget {
+ const StatusText({super.key});
+
+ @override
+ State createState() => _StatusTextState();
+}
+
+class _StatusTextState extends State {
+ String _status = 'Connecting...';
+
+ @override
+ void initState() {
+ super.initState();
+ _connect();
+ }
+
+ Future _connect() async {
+ try {
+ final address = dotenv.env['MACHINE_ADDRESS']!;
+ final apiKeyId = dotenv.env['API_KEY_ID']!;
+ final apiKey = dotenv.env['API_KEY']!;
+
+ final robot = await RobotClient.atAddress(
+ address,
+ RobotClientOptions.withApiKey(apiKeyId, apiKey),
+ );
+
+ setState(() {
+ _status = 'Connected. Found ${robot.resourceNames.length} resources.';
+ });
+ } catch (e) {
+ setState(() {
+ _status = 'Connection failed: $e';
+ });
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(_status);
+ }
+}
+```
+
+## Run the app
+
+Run the app on a connected device or simulator:
+
+```sh {class="command-line" data-prompt="$"}
+flutter run
+```
+
+If multiple targets are available, Flutter prompts you to choose. Select your device. The app builds, launches, and shows:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+If you see `Connection failed:`, the most common causes are:
+
+- **iOS or macOS permissions not granted.** The app prompts the user for local network access on first run; declining blocks WebRTC. Verify the `Info.plist` entries are present on both `ios/Runner/Info.plist` and `macos/Runner/Info.plist` if you target macOS.
+- **Android minimum SDK too low.** The Viam Flutter SDK requires Android SDK 23 or later. Raise `minSdkVersion` in `android/app/build.gradle`.
+- **`.env` not bundled.** Confirm `.env` is listed under `flutter.assets` in `pubspec.yaml` and that the file exists at the project root.
+- **Credentials wrong.** Compare the three values in `.env` to the **CONNECT** tab output.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use, including the `Viam` class for multi-machine access
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and UI indicators
+- [The Flutter SDK reference](https://flutter.viam.dev/) for per-component API details and widget documentation
diff --git a/docs/build-apps/setup/go.md b/docs/build-apps/setup/go.md
new file mode 100644
index 0000000000..680d0785e9
--- /dev/null
+++ b/docs/build-apps/setup/go.md
@@ -0,0 +1,116 @@
+---
+linkTitle: "Go setup"
+title: "Go setup"
+weight: 60
+layout: "docs"
+type: "docs"
+description: "Set up a project for writing a Viam app in Go: a backend service, a fleet orchestrator, a CLI tool, or any other Go program that talks to a Viam machine."
+date: "2026-04-13"
+---
+
+Set up a project for writing a Viam app in Go: a backend service, a fleet orchestrator, a CLI tool, or any other Go program that talks to a Viam machine. The Go client lives in the RDK package at `go.viam.com/rdk/robot/client`, not in a separate SDK. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- Go 1.21 or later
+- A configured Viam machine
+- The machine's address, an API key, and an API key ID
+
+Get the three credentials from the machine's **CONNECT** tab in the Viam app: go to the machine's page, click **CONNECT**, select **Golang**, and toggle **Include API key** on. Copy the address, API key, and API key ID from the generated code sample.
+
+## Create a project
+
+```sh {class="command-line" data-prompt="$"}
+mkdir my-viam-app
+cd my-viam-app
+go mod init my-viam-app
+```
+
+## Install the client package
+
+```sh {class="command-line" data-prompt="$"}
+go get go.viam.com/rdk/robot/client
+```
+
+This pulls the RDK client package and its dependencies. After writing the code below, run `go mod tidy` to clean up the module file.
+
+## Verify the connection
+
+Create `main.go`:
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "go.viam.com/rdk/logging"
+ "go.viam.com/rdk/robot/client"
+ "go.viam.com/utils/rpc"
+)
+
+func main() {
+ logger := logging.NewDebugLogger("client")
+
+ address := os.Getenv("MACHINE_ADDRESS")
+ apiKeyID := os.Getenv("API_KEY_ID")
+ apiKey := os.Getenv("API_KEY")
+
+ machine, err := client.New(
+ context.Background(),
+ address,
+ logger,
+ client.WithDialOptions(rpc.WithEntityCredentials(
+ apiKeyID,
+ rpc.Credentials{
+ Type: "api-key",
+ Payload: apiKey,
+ },
+ )),
+ )
+ if err != nil {
+ logger.Fatal(err)
+ }
+ defer machine.Close(context.Background())
+
+ fmt.Printf("Connected. Found %d resources.\n", len(machine.ResourceNames()))
+}
+```
+
+Run `go mod tidy` to resolve dependencies, then run with your credentials:
+
+```sh {class="command-line" data-prompt="$"}
+go mod tidy
+MACHINE_ADDRESS=my-robot-main.xxxx.viam.cloud \
+ API_KEY_ID=your-api-key-id \
+ API_KEY=your-api-key-secret \
+ go run main.go
+```
+
+You should see:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+Always call `machine.Close(ctx)` when done (or use `defer` as shown above) to release the connection cleanly.
+
+## Credentials in production
+
+The example above reads credentials from environment variables set inline on the command line. For a deployed service, set environment variables through your deployment platform (systemd unit file, Kubernetes secret, Docker Compose `.env`, or similar) rather than passing them on the command line.
+
+## Troubleshooting
+
+- **`go mod tidy` errors.** The RDK has many transitive dependencies. If `go mod tidy` fails with version conflicts, check that your Go version is 1.21 or later and that you are not inside another module's directory.
+- **Connection hangs or times out.** Verify the machine address matches the **CONNECT** tab exactly. Verify the machine is online in the Viam app.
+- **Credentials wrong.** The `rpc.WithEntityCredentials` call takes the API key ID as the first argument and the API key secret as `Payload`. Reversing them causes an authentication failure with a generic error message.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and status indicators
+- [Go SDK reference](https://pkg.go.dev/go.viam.com/rdk) for per-component API details
diff --git a/docs/build-apps/setup/node.md b/docs/build-apps/setup/node.md
new file mode 100644
index 0000000000..2a995f04f0
--- /dev/null
+++ b/docs/build-apps/setup/node.md
@@ -0,0 +1,159 @@
+---
+linkTitle: "Node.js setup"
+title: "Node.js setup"
+weight: 20
+layout: "docs"
+type: "docs"
+description: "Set up a project for running Viam SDK code from a Node.js process: a backend service, a CLI tool, or another Node app that talks to a Viam machine."
+date: "2026-04-10"
+---
+
+Set up a project for running Viam SDK code from a Node.js process: a backend service that supports a web app frontend, a CLI tool, or another Node app that talks to a Viam machine. Node.js requires extra setup compared to the browser because Node does not provide WebRTC natively: you register a WebRTC polyfill and plug in a Node-compatible gRPC transport before calling any SDK function. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- Node.js 20 or later
+- A configured Viam machine
+- The machine's host address, an API key, and an API key ID
+
+Get the three credentials from the machine's **CONNECT** tab in the Viam app: go to the machine's page, click **CONNECT**, select **TypeScript**, and toggle **Include API key** on. Copy the `host`, `authEntity` (API key ID), and `payload` (API key) from the generated code sample.
+
+## Create a project
+
+```sh {class="command-line" data-prompt="$"}
+mkdir my-viam-node-app
+cd my-viam-node-app
+npm init -y
+```
+
+## Install the SDK and dependencies
+
+Node.js needs three runtime packages:
+
+- `@viamrobotics/sdk` — the Viam SDK
+- `@connectrpc/connect-node` — the Node-compatible gRPC transport
+- `node-datachannel` — a WebRTC implementation for Node
+
+Install everything at once:
+
+```sh {class="command-line" data-prompt="$"}
+npm install @viamrobotics/sdk @connectrpc/connect-node node-datachannel
+npm install --save-dev tsx typescript @types/node
+```
+
+`tsx` runs TypeScript files directly and loads `.env` files, so you do not need a separate build step or env loader.
+
+## Configure environment variables
+
+Create a `.env` file in your project root:
+
+```text
+HOST=my-robot-main.xxxx.viam.cloud
+API_KEY_ID=your-api-key-id
+API_KEY=your-api-key-secret
+```
+
+Replace the three values with what you copied from the **CONNECT** tab.
+
+Add `.env` to your `.gitignore`:
+
+```sh {class="command-line" data-prompt="$"}
+echo ".env" >> .gitignore
+```
+
+## Verify the connection
+
+Create `src/main.ts`:
+
+```ts
+const VIAM = require("@viamrobotics/sdk");
+const wrtc = require("node-datachannel/polyfill");
+const connectNode = require("@connectrpc/connect-node");
+
+// Register a Node-compatible gRPC transport.
+// @ts-expect-error -- globalThis.VIAM is not in standard types
+globalThis.VIAM = {
+ GRPC_TRANSPORT_FACTORY: (opts: any) =>
+ connectNode.createGrpcTransport({ httpVersion: "2", ...opts }),
+};
+
+// Register WebRTC polyfills on the global object.
+for (const key in wrtc) {
+ (global as any)[key] = (wrtc as any)[key];
+}
+
+async function main() {
+ const host = process.env.HOST;
+ const apiKeyId = process.env.API_KEY_ID;
+ const apiKey = process.env.API_KEY;
+
+ if (!host || !apiKeyId || !apiKey) {
+ throw new Error("HOST, API_KEY_ID, and API_KEY must all be set in .env");
+ }
+
+ const machine = await VIAM.createRobotClient({
+ host,
+ credentials: {
+ type: "api-key",
+ authEntity: apiKeyId,
+ payload: apiKey,
+ },
+ signalingAddress: "https://app.viam.com:443",
+ });
+
+ const resources = await machine.resourceNames();
+ console.log(`Connected. Found ${resources.length} resources.`);
+ process.exit(0);
+}
+
+main().catch((err) => {
+ console.error("Connection failed:", err);
+ process.exit(1);
+});
+```
+
+Two blocks in this file are specific to Node.js and do not appear in a browser app:
+
+**The WebRTC polyfill loop.** The SDK expects browser WebRTC APIs (`RTCPeerConnection`, `RTCDataChannel`, and so on) on the global object. Node does not provide these natively, so `node-datachannel/polyfill` installs them before any SDK code runs.
+
+**The custom gRPC transport factory.** The SDK's default gRPC transport targets browsers. Node needs an HTTP/2 transport, which is what `@connectrpc/connect-node`'s `createGrpcTransport` provides. The `globalThis.VIAM.GRPC_TRANSPORT_FACTORY` hook lets you plug in an alternative transport.
+
+Both must run before any SDK function is called. If you move them to a separate module or import them later, the SDK will fail to connect.
+
+## Run the script
+
+Add a `start` script to `package.json`:
+
+```json
+{
+ "scripts": {
+ "start": "tsx --env-file=.env src/main.ts"
+ }
+}
+```
+
+Run it:
+
+```sh {class="command-line" data-prompt="$"}
+npm start
+```
+
+You should see:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+If the script hangs or errors, the most common causes are:
+
+- **Polyfills registered too late.** The polyfill loop and transport factory must run before the first call into the SDK. If you split them into a separate module that is imported after the SDK, the SDK initializes without them.
+- **`.env` not loaded.** The `tsx --env-file=.env` flag loads the file. If you run the script a different way (`node`, `ts-node`, a bundler), use `dotenv` or your runtime's env loader instead.
+- **Wrong transport.** If you see `Unsupported HTTP version` or similar, confirm that `@connectrpc/connect-node` is installed and the transport factory is registered on `globalThis.VIAM`.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and UI indicators
+- [The SDK's `Node.md`](https://github.com/viamrobotics/viam-typescript-sdk/blob/main/Node.md) for deeper detail on the polyfill and transport setup
diff --git a/docs/build-apps/setup/overview.md b/docs/build-apps/setup/overview.md
new file mode 100644
index 0000000000..5ea9ba59f9
--- /dev/null
+++ b/docs/build-apps/setup/overview.md
@@ -0,0 +1,43 @@
+---
+linkTitle: "Overview"
+title: "App scaffolding"
+weight: 1
+layout: "docs"
+type: "docs"
+description: "The pattern every Viam app project follows: create a project, install the SDK, configure credentials, and verify the connection."
+date: "2026-04-13"
+---
+
+Every Viam app follows the same scaffolding pattern regardless of language. The per-language pages in this section walk through each step for a specific SDK, but the shape is always the same:
+
+1. **Create a project.** Set up a directory with the build tooling for your language: `npm init` for TypeScript, `flutter create` for Flutter, `go mod init` for Go, a virtual environment for Python, a CMake project for C++.
+2. **Add the Viam SDK as a dependency.** Each language has its own package manager and package name: `npm install @viamrobotics/sdk` for TypeScript, `flutter pub add viam_sdk` for Flutter, `pip install viam-sdk` for Python, `go get go.viam.com/rdk/robot/client` for Go, and a CMake `find_package` for C++. Some platforms need additional dependencies (WebRTC polyfills for Node.js, platform permissions for iOS and Android).
+3. **Configure credentials.** Store your machine's address, API key, and API key ID in environment variables or a `.env` file. Never commit credentials to source control.
+4. **Write a connection verification.** A short program that connects to your machine and prints the resource count. This confirms the SDK is installed correctly, credentials are valid, and the connection works.
+5. **Run it.** Execute the program and confirm you see `Connected. Found N resources.` If you do, the scaffold is complete and you can start writing your app.
+
+After scaffolding, the next step is [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use, or one of the [tutorials](/build-apps/app-tutorials/tutorial-dashboard/) for a guided project.
+
+## Pick your language
+
+| Language | Page | Notes |
+| -------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| TypeScript (browser) | [TypeScript setup](/build-apps/setup/typescript/) | Web app: dashboards, operator interfaces, any browser-based app. The setup page uses Vite; any bundler that handles TypeScript and ESM works. |
+| TypeScript (Node.js) | [Node.js setup](/build-apps/setup/node/) | Node process for backend services and CLI tools. Requires WebRTC polyfills and a custom gRPC transport. |
+| Flutter | [Flutter setup](/build-apps/setup/flutter/) | Cross-platform project for iOS, Android, and desktop. Includes iOS/Android platform configuration. |
+| React Native | [React Native setup](/build-apps/setup/react-native/) | Mobile project for teams with existing React Native codebases. Requires six polyfill packages and a custom transport. For new projects, prefer Flutter. |
+| Python | [Python setup](/build-apps/setup/python/) | Virtual environment for scripts, services, and backend integrations |
+| Go | [Go setup](/build-apps/setup/go/) | Go module for backend services, fleet orchestrators, and CLI tools |
+| C++ | [C++ setup](/build-apps/setup/cpp/) | CMake project for embedded and high-performance apps. Requires system-level dependencies (Boost, gRPC, protobuf). |
+
+## Where credentials come from
+
+Every scaffolding page asks for three values: the machine's address, an API key, and an API key ID. Get all three from the same place:
+
+1. Go to the machine's page in the Viam app.
+2. Click the **CONNECT** tab.
+3. Select the language tab that matches the page you are following.
+4. Toggle **Include API key** on.
+5. Copy the address, API key ID, and API key from the generated code sample.
+
+For apps that access multiple machines or the Viam cloud APIs, use an organization-scoped or location-scoped API key instead of a machine-scoped one. Create these in [Admin and access](/organization/access/).
diff --git a/docs/build-apps/setup/python.md b/docs/build-apps/setup/python.md
new file mode 100644
index 0000000000..bb73e2d52f
--- /dev/null
+++ b/docs/build-apps/setup/python.md
@@ -0,0 +1,126 @@
+---
+linkTitle: "Python setup"
+title: "Python setup"
+weight: 50
+layout: "docs"
+type: "docs"
+description: "Set up a project for writing a Viam app in Python: a control script, a backend service, a data pipeline, or any other Python program that talks to a Viam machine."
+date: "2026-04-13"
+---
+
+Set up a project for writing a Viam app in Python: a control script, a backend service, a data pipeline, or any other Python program that talks to a Viam machine. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- Python 3.9 or later
+- A configured Viam machine
+- The machine's address, an API key, and an API key ID
+
+Get the three credentials from the machine's **CONNECT** tab in the Viam app: go to the machine's page, click **CONNECT**, select **Python**, and toggle **Include API key** on. Copy the address, API key, and API key ID from the generated code sample.
+
+## Create a project
+
+Create a directory for your project and set up a virtual environment:
+
+```sh {class="command-line" data-prompt="$"}
+mkdir my-viam-app
+cd my-viam-app
+python3 -m venv .venv
+source .venv/bin/activate
+```
+
+## Install the SDK
+
+```sh {class="command-line" data-prompt="$"}
+pip install viam-sdk
+```
+
+Pre-built binaries are available for macOS (Intel and Apple Silicon) and Linux (x86_64, aarch64, armv6l). On Windows, WebRTC is not supported natively; use WSL or connect with `disable_webrtc=True` in the dial options.
+
+If you need the ML model service, install with the optional dependency:
+
+```sh {class="command-line" data-prompt="$"}
+pip install 'viam-sdk[mlmodel]'
+```
+
+## Configure credentials
+
+Create a `.env` file in your project root:
+
+```text
+MACHINE_ADDRESS=my-robot-main.xxxx.viam.cloud
+API_KEY_ID=your-api-key-id
+API_KEY=your-api-key-secret
+```
+
+Replace the three values with what you copied from the **CONNECT** tab.
+
+Add `.env` to your `.gitignore`:
+
+```sh {class="command-line" data-prompt="$"}
+echo ".env" >> .gitignore
+```
+
+Install `python-dotenv` to load the file:
+
+```sh {class="command-line" data-prompt="$"}
+pip install python-dotenv
+```
+
+## Verify the connection
+
+Create `main.py`:
+
+```python
+import asyncio
+import os
+
+from dotenv import load_dotenv
+from viam.robot.client import RobotClient
+
+load_dotenv()
+
+
+async def main():
+ opts = RobotClient.Options.with_api_key(
+ api_key=os.environ["API_KEY"],
+ api_key_id=os.environ["API_KEY_ID"],
+ )
+ machine = await RobotClient.at_address(os.environ["MACHINE_ADDRESS"], opts)
+
+ print(f"Connected. Found {len(machine.resource_names)} resources.")
+
+ await machine.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+Run it:
+
+```sh {class="command-line" data-prompt="$"}
+python main.py
+```
+
+You should see:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+All Viam Python SDK methods are async. Your app code runs inside `asyncio.run()` and uses `await` for every SDK call. Always call `machine.close()` when done to release the connection.
+
+## Troubleshooting
+
+- **`ModuleNotFoundError: No module named 'viam'`.** Confirm the virtual environment is activated (`source .venv/bin/activate`) and that you installed the SDK inside it.
+- **Connection hangs on Windows.** WebRTC is not supported on native Windows. Either use WSL, or pass `disable_webrtc=True` in your `DialOptions` to fall back to direct gRPC.
+- **Credentials wrong.** Compare the three values in `.env` to the **CONNECT** tab output.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and status indicators
+- [The Python SDK reference](https://python.viam.dev/) for per-component API details
diff --git a/docs/build-apps/setup/react-native.md b/docs/build-apps/setup/react-native.md
new file mode 100644
index 0000000000..9468b20906
--- /dev/null
+++ b/docs/build-apps/setup/react-native.md
@@ -0,0 +1,202 @@
+---
+linkTitle: "React Native setup"
+title: "React Native setup"
+weight: 40
+layout: "docs"
+type: "docs"
+description: "Set up a project for building a Viam mobile app using React Native specifically. For new cross-platform apps, prefer Flutter."
+date: "2026-04-10"
+---
+
+Set up a project for building a Viam mobile app using React Native specifically. **If you do not already have a React Native codebase, prefer [Flutter](/build-apps/setup/flutter/) for new cross-platform apps; React Native is here for teams whose existing apps are React Native.** This page covers the project scaffolding, the Viam SDK install, six runtime polyfill packages, a custom XHR-based gRPC transport, a Metro bundler configuration fix for a dependency version conflict, and platform permissions on Android and iOS. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+{{< alert title="Expo is not supported" color="caution" >}}
+The Viam TypeScript SDK does not work with Expo (neither Expo Go nor development builds). You must use the React Native CLI. If your project uses Expo, you cannot add the Viam SDK without ejecting to a bare workflow.
+{{< /alert >}}
+
+## Prerequisites
+
+- The React Native CLI environment for your development platform ([React Native environment setup](https://reactnative.dev/docs/environment-setup))
+- Xcode for iOS targets, Android Studio for Android targets
+- A configured Viam machine
+- The machine's address, an API key, and an API key ID
+
+Get the credentials from the machine's **CONNECT** tab in the Viam app. Click **CONNECT**, select **TypeScript**, toggle **Include API key** on, and copy the `host`, `authEntity`, and `payload` values.
+
+## Create a project
+
+```sh {class="command-line" data-prompt="$"}
+npx @react-native-community/cli@latest init MyViamApp
+cd MyViamApp
+```
+
+## Install the SDK and dependencies
+
+The Viam SDK needs six runtime packages in addition to itself:
+
+```sh {class="command-line" data-prompt="$"}
+npm install @viamrobotics/sdk \
+ fast-text-encoding \
+ react-native-fast-encoder \
+ react-native-fetch-api \
+ react-native-url-polyfill \
+ react-native-webrtc \
+ web-streams-polyfill
+```
+
+After the install completes, run `pod install` for iOS:
+
+```sh {class="command-line" data-prompt="$"}
+cd ios && pod install && cd ..
+```
+
+## Add polyfill files
+
+The SDK expects browser-style globals (`TextEncoder`, `fetch`, `Headers`, `Request`, `Response`, `ReadableStream`, WebRTC APIs) that React Native does not provide natively. The polyfill files from the SDK example repo register these globals before the SDK initializes.
+
+Create `polyfills.native.ts` in your project root with the contents from the [SDK example's `polyfills.native.ts`](https://github.com/viamrobotics/viam-typescript-sdk/blob/main/examples/react-native/polyfills.native.ts). The file is stable and you can copy it as-is.
+
+Also create `polyfills.ts` (no-op fallback for web builds) with the contents from [the SDK example's `polyfills.ts`](https://github.com/viamrobotics/viam-typescript-sdk/blob/main/examples/react-native/polyfills.ts).
+
+## Add the custom gRPC transport
+
+React Native's `fetch` does not support the streaming and binary-body semantics that Connect's default gRPC-web transport requires. The SDK example provides an XHR-based transport that works around this. Copy it from the SDK example:
+
+Create `transport.ts` in your project root with the contents from [the SDK example's `transport.ts`](https://github.com/viamrobotics/viam-typescript-sdk/blob/main/examples/react-native/transport.ts). Do not modify it. The transport implementation is verbose (roughly 350 lines) and stable; treating it as a copy-and-forget dependency is the intended approach.
+
+## Update metro.config.js
+
+`react-native` and `react-native-webrtc` depend on conflicting versions of `event-target-shim`. Metro's default resolver picks the wrong one, which causes runtime errors. Tell Metro to resolve `event-target-shim` inside `react-native-webrtc` using its own nested version:
+
+```js
+const { getDefaultConfig } = require("@react-native/metro-config");
+const resolveFrom = require("resolve-from");
+
+const config = getDefaultConfig(__dirname);
+
+config.resolver.resolveRequest = (context, moduleName, platform) => {
+ if (
+ moduleName.startsWith("event-target-shim") &&
+ context.originModulePath.includes("react-native-webrtc")
+ ) {
+ return {
+ filePath: resolveFrom(context.originModulePath, moduleName),
+ type: "sourceFile",
+ };
+ }
+ return context.resolveRequest(context, moduleName, platform);
+};
+
+module.exports = config;
+```
+
+Save this as `metro.config.js` in your project root, replacing whatever `react-native init` generated.
+
+## Add Android permission
+
+React Native apps targeting Android need the `ACCESS_NETWORK_STATE` permission for WebRTC. Add it to `android/app/src/main/AndroidManifest.xml` inside the top-level `` element:
+
+```xml
+
+```
+
+## Register polyfills and transport in App.tsx
+
+Replace the contents of `App.tsx` with:
+
+```tsx
+import * as VIAM from "@viamrobotics/sdk";
+import { polyfills } from "./polyfills";
+polyfills();
+
+import { GrpcWebTransportOptions } from "@connectrpc/connect-web";
+import { createXHRGrpcWebTransport } from "./transport";
+
+// @ts-expect-error -- globalThis.VIAM is not in standard types
+globalThis.VIAM = {
+ GRPC_TRANSPORT_FACTORY: (opts: GrpcWebTransportOptions) => {
+ return createXHRGrpcWebTransport(opts);
+ },
+};
+
+import React, { useEffect, useState } from "react";
+import { SafeAreaView, Text, StyleSheet } from "react-native";
+
+// Replace these with the values from your machine's CONNECT tab.
+// For a production app, use react-native-config or a secure store
+// instead of hardcoding credentials in source.
+const HOST = "my-robot-main.xxxx.viam.cloud";
+const API_KEY_ID = "your-api-key-id";
+const API_KEY = "your-api-key-secret";
+
+function App(): React.JSX.Element {
+ const [status, setStatus] = useState("Connecting...");
+
+ useEffect(() => {
+ async function connect() {
+ try {
+ const machine = await VIAM.createRobotClient({
+ host: HOST,
+ credentials: {
+ type: "api-key",
+ authEntity: API_KEY_ID,
+ payload: API_KEY,
+ },
+ signalingAddress: "https://app.viam.com:443",
+ });
+ const resources = await machine.resourceNames();
+ setStatus(`Connected. Found ${resources.length} resources.`);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setStatus(`Connection failed: ${msg}`);
+ }
+ }
+ connect();
+ }, []);
+
+ return (
+
+ {status}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, justifyContent: "center", alignItems: "center" },
+ text: { fontSize: 18 },
+});
+
+export default App;
+```
+
+**The order of imports in this file matters.** The polyfills and the transport factory assignment must run before any SDK call. If you move the `polyfills()` call or the `globalThis.VIAM` assignment below the React imports, or import any SDK submodule before them, the SDK will fail to initialize.
+
+## Run the app
+
+```sh {class="command-line" data-prompt="$"}
+npm run ios
+# or
+npm run android
+```
+
+The app builds, launches on the selected simulator or device, and shows:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services on your machine.
+
+If you see `Connection failed:`, the most common causes are:
+
+- **Polyfills imported out of order.** The `polyfills()` call and the `globalThis.VIAM` assignment must appear before any other code that touches the SDK, including React imports if you reference SDK types in them.
+- **`metro.config.js` not applied.** Metro caches aggressively. Stop the Metro bundler and run `npm start -- --reset-cache` after editing `metro.config.js`.
+- **Android permission missing.** Confirm `ACCESS_NETWORK_STATE` is in `AndroidManifest.xml` and rebuild the Android app.
+- **iOS pods out of date.** Run `cd ios && pod install && cd ..` again after any native dependency change.
+- **Using Expo.** If you started from an Expo project, none of this setup will work. You must use a bare React Native project.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and UI indicators
+- [The SDK's `ReactNative.md`](https://github.com/viamrobotics/viam-typescript-sdk/blob/main/ReactNative.md) for deeper detail on the polyfills, the transport, and the Android environment variables needed for the Android build
diff --git a/docs/build-apps/setup/typescript.md b/docs/build-apps/setup/typescript.md
new file mode 100644
index 0000000000..e8f19189e0
--- /dev/null
+++ b/docs/build-apps/setup/typescript.md
@@ -0,0 +1,138 @@
+---
+linkTitle: "TypeScript setup"
+title: "TypeScript setup"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "Set up a project for building a Viam web app with TypeScript: a custom dashboard, an operator interface, or any other browser-based app that talks to a Viam machine."
+date: "2026-04-10"
+---
+
+Set up a project for building a Viam web app with TypeScript: a custom dashboard, an operator interface, or any other browser-based app that talks to a Viam machine. This page covers the project scaffolding with [Vite](https://vitejs.dev/) and the Viam TypeScript SDK install. For the connection patterns your app will actually use, see [Connect to a machine](/build-apps/tasks/connect-to-machine/).
+
+## Prerequisites
+
+- Node.js 20 or later
+- A configured Viam machine
+- The machine's host address, an API key, and an API key ID
+
+Get all three credentials from the machine's **CONNECT** tab in the Viam app: go to the machine's page, click **CONNECT**, select **TypeScript**, and toggle **Include API key** on. Copy the `host`, `authEntity` (the API key ID), and `payload` (the API key) from the generated code sample.
+
+## Create a project
+
+Create a directory for your project and initialize `package.json`.
+
+```sh {class="command-line" data-prompt="$"}
+mkdir my-viam-app
+cd my-viam-app
+npm init -y
+```
+
+Open `package.json` and add `"type": "module"` so Node treats `.js` files as ES modules:
+
+```json
+{
+ "name": "my-viam-app",
+ "type": "module",
+ "version": "1.0.0"
+}
+```
+
+## Install the SDK and Vite
+
+```sh {class="command-line" data-prompt="$"}
+npm install @viamrobotics/sdk
+npm install --save-dev vite typescript
+```
+
+## Configure environment variables
+
+Create a `.env` file in your project root:
+
+```text
+VITE_HOST=my-robot-main.xxxx.viam.cloud
+VITE_API_KEY_ID=your-api-key-id
+VITE_API_KEY=your-api-key-secret
+```
+
+Replace the three values with what you copied from the **CONNECT** tab. Vite exposes environment variables prefixed with `VITE_` to browser code through `import.meta.env`. See [the Vite env docs](https://vitejs.dev/guide/env-and-mode.html) for details.
+
+Add `.env` to your `.gitignore` so you do not accidentally commit credentials:
+
+```sh {class="command-line" data-prompt="$"}
+echo ".env" >> .gitignore
+```
+
+## Create the HTML entry point
+
+Create `index.html` in your project root:
+
+```html
+
+
+
+
+ My Viam App
+
+
+
+
+
+
+```
+
+## Verify the connection
+
+Create `src/main.ts`:
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const statusEl = document.getElementById("status") as HTMLDivElement;
+
+async function main() {
+ statusEl.textContent = "Connecting...";
+ try {
+ const machine = await VIAM.createRobotClient({
+ host: import.meta.env.VITE_HOST,
+ credentials: {
+ type: "api-key",
+ authEntity: import.meta.env.VITE_API_KEY_ID,
+ payload: import.meta.env.VITE_API_KEY,
+ },
+ signalingAddress: "https://app.viam.com:443",
+ });
+ const resources = await machine.resourceNames();
+ statusEl.textContent = `Connected. Found ${resources.length} resources.`;
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ statusEl.textContent = `Connection failed: ${msg}`;
+ }
+}
+
+main();
+```
+
+This connects to your machine, fetches the list of configured resources, and shows the count in the page.
+
+## Run the dev server
+
+```sh {class="command-line" data-prompt="$"}
+npx vite
+```
+
+Vite prints a local URL, typically `http://localhost:5173`. Open it in a browser. You should see:
+
+```text
+Connected. Found N resources.
+```
+
+where `N` is the number of components and services configured on your machine. If the page shows `Connection failed:` followed by an error, check that the three values in your `.env` file match the **CONNECT** tab exactly, including the `https://` in the host if the generated sample includes one.
+
+If you use Firefox for development, see the [Firefox WebRTC localhost workaround](/build-apps/tasks/test-locally/#firefox-webrtc-localhost-workaround) on the Test against a local machine page. Firefox blocks WebRTC connections from `localhost`, so you need a small `/etc/hosts` adjustment to develop locally.
+
+## Next
+
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the connection patterns your app will actually use, including language tabs for Flutter
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection and UI indicators
+- [Stream video](/build-apps/tasks/stream-video/) for displaying camera feeds
diff --git a/docs/build-apps/tasks/_index.md b/docs/build-apps/tasks/_index.md
new file mode 100644
index 0000000000..fac7beb412
--- /dev/null
+++ b/docs/build-apps/tasks/_index.md
@@ -0,0 +1,11 @@
+---
+linkTitle: "Common tasks"
+title: "Common tasks"
+weight: 30
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/build-apps/tasks/connect-to-machine/"
+description: "Connect to machines, stream video, query data, handle disconnections, and test locally."
+date: "2026-04-13"
+---
diff --git a/docs/build-apps/tasks/connect-to-cloud.md b/docs/build-apps/tasks/connect-to-cloud.md
new file mode 100644
index 0000000000..ee9a5964bf
--- /dev/null
+++ b/docs/build-apps/tasks/connect-to-cloud.md
@@ -0,0 +1,246 @@
+---
+linkTitle: "Connect to the Viam cloud"
+title: "Connect to the Viam cloud"
+weight: 40
+layout: "docs"
+type: "docs"
+description: "Open a connection to the Viam cloud to access the fleet, data, ML training, billing, and provisioning APIs, and to enumerate and connect to multiple machines."
+date: "2026-04-10"
+---
+
+Open a connection to the Viam cloud to access the fleet, data, ML training, billing, and provisioning APIs, and to enumerate and connect to multiple machines from one app. For connecting directly to a single known machine, use [Connect to a machine](/build-apps/tasks/connect-to-machine/) instead.
+
+## When to connect to the cloud
+
+Use the cloud connection when your app needs any of these:
+
+- **Multiple machines.** Your app lets users pick from a fleet, shows status across many devices, or connects to different machines based on user input.
+- **Machine discovery.** Your app does not know the machine address ahead of time and needs to list machines from an organization or location.
+- **Captured data queries.** Your app queries sensor or binary data with SQL or MQL (see [Query captured data](/build-apps/tasks/query-data/)).
+- **Fleet management.** Your app creates or manages machines, API keys, locations, or fragments programmatically.
+- **Custom provisioning.** Your app onboards new machines through the provisioning service (see [Provision devices](/fleet/provision-devices/)).
+- **Billing or ML training.** Your app reads billing information or launches ML training jobs.
+
+If your app only talks to one machine whose address you already know, skip this page and use [Connect to a machine](/build-apps/tasks/connect-to-machine/) directly.
+
+## Prerequisites
+
+- A project set up with the Viam SDK (see [App scaffolding](/build-apps/setup/))
+- An API key scoped to the organization or location your app needs to access, and its API key ID. Create an API key in [Admin and access](/organization/access/).
+
+## Open a cloud connection
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const client = await VIAM.createViamClient({
+ credentials: {
+ type: "api-key",
+ authEntity: "",
+ payload: "",
+ },
+});
+```
+
+`createViamClient` returns a `ViamClient` with five platform-client properties:
+
+| Property | Purpose |
+| --------------------------- | ------------------------------------------------------------- |
+| `client.appClient` | Organizations, locations, machines, API keys, fragments, RBAC |
+| `client.dataClient` | Query and upload captured data |
+| `client.mlTrainingClient` | Launch and monitor ML training jobs |
+| `client.billingClient` | Read billing information |
+| `client.provisioningClient` | Device provisioning for custom provisioning apps |
+
+The default `serviceHost` is `https://app.viam.com`. Override it only if you are pointing at a self-hosted Viam platform.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+import 'package:viam_sdk/viam_sdk.dart';
+
+final viam = await Viam.withApiKey(
+ 'your-api-key-id',
+ 'your-api-key-secret',
+);
+```
+
+`Viam.withApiKey` returns a `Viam` instance with five platform-client properties:
+
+| Property | Purpose |
+| ------------------------- | ------------------------------------------------------------- |
+| `viam.appClient` | Organizations, locations, machines, API keys, fragments, RBAC |
+| `viam.dataClient` | Query and upload captured data |
+| `viam.mlTrainingClient` | Launch and monitor ML training jobs |
+| `viam.billingClient` | Read billing information |
+| `viam.provisioningClient` | Device provisioning for custom provisioning apps |
+
+The default `serviceHost` is `app.viam.com`. Override it through the optional `serviceHost` parameter if you are pointing at a self-hosted Viam platform.
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+from viam.app.viam_client import ViamClient
+from viam.rpc.dial import DialOptions
+
+
+async def connect():
+ dial_options = DialOptions.with_api_key(
+ api_key='your-api-key-secret',
+ api_key_id='your-api-key-id'
+ )
+ return await ViamClient.create_from_dial_options(dial_options)
+
+
+client = await connect()
+```
+
+`ViamClient` exposes the same platform clients:
+
+| Property | Purpose |
+| ---------------------------- | ------------------------------------------------------------- |
+| `client.app_client` | Organizations, locations, machines, API keys, fragments, RBAC |
+| `client.data_client` | Query and upload captured data |
+| `client.ml_training_client` | Launch and monitor ML training jobs |
+| `client.billing_client` | Read billing information |
+| `client.provisioning_client` | Device provisioning for custom provisioning apps |
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+import (
+ "context"
+ "go.viam.com/rdk/app"
+ "go.viam.com/rdk/logging"
+)
+
+logger := logging.NewDebugLogger("client")
+client, err := app.CreateViamClientWithAPIKey(
+ context.Background(),
+ app.Options{},
+ "your-api-key-secret",
+ "your-api-key-id",
+ logger,
+)
+if err != nil {
+ logger.Fatal(err)
+}
+```
+
+Access platform clients through methods on `ViamClient`:
+
+| Method | Purpose |
+| ----------------------------- | ------------------------------------------------------------- |
+| `client.AppClient()` | Organizations, locations, machines, API keys, fragments, RBAC |
+| `client.DataClient()` | Query and upload captured data |
+| `client.MLTrainingClient()` | Launch and monitor ML training jobs |
+| `client.BillingClient()` | Read billing information |
+| `client.ProvisioningClient()` | Device provisioning |
+
+{{% /tab %}}
+{{< /tabs >}}
+
+Unlike `RobotClient`, the cloud client does not hold a persistent WebRTC connection. Each method call is a separate request to the Viam cloud. There is no `close()` or `disconnect()` method to call when you are done.
+
+## Enumerate machines
+
+Common pattern: list the organizations a user can access, then list machines in a selected organization.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const orgs = await client.appClient.listOrganizations();
+console.log(`Found ${orgs.length} organizations`);
+
+const orgId = orgs[0].id;
+const summaries = await client.appClient.listMachineSummaries(orgId);
+for (const location of summaries) {
+ console.log(`Location: ${location.locationName}`);
+ for (const machine of location.machines) {
+ console.log(` ${machine.machineName} (${machine.machineId})`);
+ }
+}
+```
+
+`listMachineSummaries` returns a list of location summaries, each containing the machines in that location. Pass `fragmentIds` to filter to machines that include specific fragments, or `locationIds` to scope to particular locations.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+final orgs = await viam.appClient.listOrganizations();
+print('Found ${orgs.length} organizations');
+
+final orgId = orgs.first.id;
+final locations = await viam.appClient.listLocations(orgId);
+
+for (final location in locations) {
+ print('Location: ${location.name}');
+ final robots = await viam.appClient.listRobots(location.id);
+ for (final robot in robots) {
+ print(' ${robot.name} (${robot.id})');
+ }
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+See [the fleet API reference](/reference/apis/fleet/) for the full method list on `AppClient`.
+
+## Connect to a machine from the cloud client
+
+Once you have identified a machine, open a `RobotClient` connection to it.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const machine = await client.connectToMachine({
+ id: "abc-123-def-456",
+});
+```
+
+`connectToMachine` accepts either `{ host: "..." }` with the machine's FQDN or `{ id: "..." }` with the machine's UUID. It returns a `RobotClient` that behaves the same as one created by `createRobotClient`.
+
+Note that `connectToMachine` uses `reconnectMaxAttempts: 1` instead of the default of 10. If you need the standard reconnection behavior, call `createRobotClient` directly with the host and credentials you obtained from the cloud client.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+final locations = await viam.appClient.listLocations(orgId);
+final location = locations.first;
+final robots = await viam.appClient.listRobots(location.id);
+final robot = robots.first;
+
+final robotClient = await viam.getRobotClient(robot);
+```
+
+`getRobotClient` takes a `Robot` object (returned from `appClient.listRobots` or `appClient.getRobot`) and returns a `RobotClient` connected to the machine's main part.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+Close the `RobotClient` when your app is done with the machine. See [Connect to a machine](/build-apps/tasks/connect-to-machine/) for the close pattern and for error handling.
+
+## Handle errors
+
+API key errors, network failures, and missing permissions all raise errors from `createViamClient` or `Viam.withApiKey`. Wrap the call in a try-catch the same way you would for a machine connection. The cloud client does not distinguish credential errors from network errors in its error messages; log the error verbatim when debugging.
+
+If a subsequent `appClient` or `dataClient` call fails because the API key does not have permission for the target resource, the SDK throws an error from that specific call, not from the initial `createViamClient`. Check errors on every cloud method call that accesses resources across organizations or locations.
+
+## Next
+
+- [Query captured data](/build-apps/tasks/query-data/) for using `dataClient` to read sensor and binary data
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection behavior on the `RobotClient` instances you get from the cloud connection
+- [Provision devices](/fleet/provision-devices/) for custom provisioning apps that use `provisioningClient`
+- [Fleet API reference](/reference/apis/fleet/) for the full `AppClient` method list
+- [Data API reference](/reference/apis/data-client/) for the full `DataClient` method list
diff --git a/docs/build-apps/tasks/connect-to-machine.md b/docs/build-apps/tasks/connect-to-machine.md
new file mode 100644
index 0000000000..3e34b64346
--- /dev/null
+++ b/docs/build-apps/tasks/connect-to-machine.md
@@ -0,0 +1,248 @@
+---
+linkTitle: "Connect to a machine"
+title: "Connect to a machine"
+weight: 30
+layout: "docs"
+type: "docs"
+description: "Open a connection to a single Viam machine from your app, structure the connection code correctly, and close the connection when your app is done with it."
+date: "2026-04-10"
+---
+
+Open a connection to a single Viam machine from your app, structure the connection code so you do not reconnect unnecessarily, and close the connection when the app is done with it. This page covers the basic connection pattern. For reconnection, UI indicators, and connection-state events, see [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/). For apps that access multiple machines or the fleet APIs, see [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/).
+
+## Prerequisites
+
+- A project set up with the Viam SDK (see [App scaffolding](/build-apps/setup/))
+- The machine's address, an API key, and an API key ID, copied from the machine's **CONNECT** tab in the Viam app
+
+## Open a connection
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const machine = await VIAM.createRobotClient({
+ host: "my-robot-main.xxxx.viam.cloud",
+ credentials: {
+ type: "api-key",
+ authEntity: "",
+ payload: "",
+ },
+ signalingAddress: "https://app.viam.com:443",
+});
+```
+
+`createRobotClient` returns a `RobotClient` connected to your machine. The `await` resolves once the WebRTC connection is established and the session has started.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+import 'package:viam_sdk/viam_sdk.dart';
+
+final robot = await RobotClient.atAddress(
+ 'my-robot-main.xxxx.viam.cloud',
+ RobotClientOptions.withApiKey('your-api-key-id', 'your-api-key-secret'),
+);
+```
+
+`RobotClient.atAddress` returns a `RobotClient` connected to your machine. The `await` resolves once the WebRTC connection is established and the session has started.
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+import asyncio
+from viam.robot.client import RobotClient
+
+
+async def connect():
+ opts = RobotClient.Options.with_api_key(
+ api_key='your-api-key-secret',
+ api_key_id='your-api-key-id'
+ )
+ return await RobotClient.at_address('my-robot-main.xxxx.viam.cloud', opts)
+
+
+machine = await connect()
+```
+
+`RobotClient.at_address` is async. All Viam Python SDK methods use `async`/`await` and run inside `asyncio.run()`.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+import (
+ "context"
+ "go.viam.com/rdk/logging"
+ "go.viam.com/rdk/robot/client"
+ "go.viam.com/utils/rpc"
+)
+
+logger := logging.NewDebugLogger("client")
+machine, err := client.New(
+ context.Background(),
+ "my-robot-main.xxxx.viam.cloud",
+ logger,
+ client.WithDialOptions(rpc.WithEntityCredentials(
+ "your-api-key-id",
+ rpc.Credentials{
+ Type: "api-key",
+ Payload: "your-api-key-secret",
+ },
+ )),
+)
+if err != nil {
+ logger.Fatal(err)
+}
+```
+
+`client.New` returns a `*RobotClient` and an error. Always check the error before using the client.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+For an explanation of what the SDK does during connection (WebRTC signaling, ICE, sessions, reconnection), see [Connection model](/build-apps/concepts/how-apps-connect/). The defaults work for machines deployed on Viam Cloud; you override them only for self-hosted or local-network setups.
+
+## Close the connection
+
+Close the connection when your app is done with it. Closing releases resources, ends the session, and stops background reconnection attempts.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+await machine.disconnect();
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+await robot.close();
+```
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+await machine.close()
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+machine.Close(context.Background())
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+In a browser app, the tab closing eventually tears down the connection on its own, but closing explicitly avoids a race where pending operations continue after the page unloads. In a Node.js script, always close before `process.exit()` or the script may hang on an open WebRTC connection. In a Flutter app, close in `State.dispose()` if the connection was created per-screen, or in a service class's dispose method if the connection is app-wide.
+
+## Handle errors during connect
+
+Network failures, wrong credentials, and unreachable machines all raise errors from the connect call. Wrap the call in a try-catch:
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+let machine: VIAM.RobotClient;
+try {
+ machine = await VIAM.createRobotClient({
+ host: "my-robot-main.xxxx.viam.cloud",
+ credentials: {
+ type: "api-key",
+ authEntity: "",
+ payload: "",
+ },
+ signalingAddress: "https://app.viam.com:443",
+ });
+} catch (err) {
+ console.error("Failed to connect:", err);
+ return;
+}
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+late RobotClient robot;
+try {
+ robot = await RobotClient.atAddress(
+ 'my-robot-main.xxxx.viam.cloud',
+ RobotClientOptions.withApiKey('your-api-key-id', 'your-api-key-secret'),
+ );
+} catch (e) {
+ print('Failed to connect: $e');
+ return;
+}
+```
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python {class="line-numbers linkable-line-numbers" data-line=""}
+try:
+ opts = RobotClient.Options.with_api_key(
+ api_key='your-api-key-secret',
+ api_key_id='your-api-key-id'
+ )
+ machine = await RobotClient.at_address(
+ 'my-robot-main.xxxx.viam.cloud', opts
+ )
+except Exception as e:
+ print(f"Failed to connect: {e}")
+ # In a real app: raise, sys.exit(1), or handle appropriately
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+machine, err := client.New(
+ context.Background(),
+ "my-robot-main.xxxx.viam.cloud",
+ logger,
+ client.WithDialOptions(rpc.WithEntityCredentials(
+ "your-api-key-id",
+ rpc.Credentials{
+ Type: "api-key",
+ Payload: "your-api-key-secret",
+ },
+ )),
+)
+if err != nil {
+ logger.Fatalf("Failed to connect: %v", err)
+}
+defer machine.Close(context.Background())
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+The error from a failed connect is generic. The SDK does not always distinguish credential errors from network errors. Log the error message verbatim when debugging, and check the **CONNECT** tab in the Viam app to confirm the machine is online before assuming the code is wrong.
+
+Once the connection is established, subsequent network drops trigger automatic reconnection rather than thrown errors. See [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for the reconnection pattern.
+
+## Where to put connection code
+
+Create the `RobotClient` once at the right lifetime boundary and share it across the parts of your app that need it. Do not call `createRobotClient` or `RobotClient.atAddress` inside a render function or a click handler: each call opens a new connection and orphans the previous one.
+
+- **Browser SPA.** Create the client in your app's root component or in a service module imported at startup. Pass it down through props, context, or a state store. Close once on app unmount.
+- **Node.js script.** Create the client at the top of `main()` and close it before `process.exit()`. For long-running services, keep the client alive and let automatic reconnection handle network drops.
+- **Python script or service.** Create the client at the top of your `main()` coroutine. Use `async with` or call `await machine.close()` in a `finally` block. For long-running services, keep the client alive for the service's lifetime.
+- **Go service.** Create the client at the top of `main()` and `defer machine.Close(ctx)`. For long-running services, keep the client alive and let automatic reconnection handle drops.
+- **Flutter app.** Create the client in `initState` or a service class (Provider, Riverpod, or similar). Dispose in `dispose()`. For apps with multiple screens, put the client in a service class rather than in each screen's state.
+
+## Next
+
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection behavior, UI indicators, and connection events
+- [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/) for apps that access multiple machines or use the fleet, data, and billing APIs
+- [Stream video](/build-apps/tasks/stream-video/) for displaying camera feeds from your connected machine
diff --git a/docs/build-apps/tasks/control-components.md b/docs/build-apps/tasks/control-components.md
new file mode 100644
index 0000000000..9b56cffb97
--- /dev/null
+++ b/docs/build-apps/tasks/control-components.md
@@ -0,0 +1,228 @@
+---
+linkTitle: "Control components"
+title: "Control components"
+weight: 55
+layout: "docs"
+type: "docs"
+description: "Read from sensors and control motors, arms, and other actuators from your app using the SDK component clients."
+date: "2026-04-13"
+---
+
+Read sensor data and send commands to motors, arms, grippers, and other actuators from your app. Each component type has its own SDK client with methods specific to that type. This page shows the pattern for getting a component client and calling its methods.
+
+For the full list of component types and their methods, see the SDK reference for your language: [TypeScript](https://ts.viam.dev/), [Python](https://python.viam.dev/), [Flutter](https://flutter.viam.dev/), [Go](https://pkg.go.dev/go.viam.com/rdk), or [C++](https://cpp.viam.dev/).
+
+## Prerequisites
+
+- A project with an active machine connection (see [Connect to a machine](/build-apps/tasks/connect-to-machine/))
+- At least one component configured on the machine (sensor, motor, camera, arm, or any other type). If you do not have physical hardware, add a fake component in the Viam app's **CONFIGURE** tab (`fake:sensor`, `fake:motor`, and so on).
+
+## Get a component client
+
+Every component type follows the same pattern: you get a typed client from the connected machine by name, then call methods on that client.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const sensor = new VIAM.SensorClient(machine, "my_sensor");
+const motor = new VIAM.MotorClient(machine, "my_motor");
+```
+
+In TypeScript, you construct a client by passing the `RobotClient` and the component name.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+import 'package:viam_sdk/viam_sdk.dart';
+
+final sensor = Sensor.fromRobot(robot, 'my_sensor');
+final motor = Motor.fromRobot(robot, 'my_motor');
+```
+
+In Flutter, each component type has a `fromRobot` factory method.
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+from viam.components.sensor import Sensor
+from viam.components.motor import Motor
+
+sensor = Sensor.from_robot(robot=machine, name="my_sensor")
+motor = Motor.from_robot(robot=machine, name="my_motor")
+```
+
+In Python, each component type has a `from_robot` class method.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+import (
+ "go.viam.com/rdk/components/sensor"
+ "go.viam.com/rdk/components/motor"
+)
+
+mySensor, err := sensor.FromProvider(machine, "my_sensor")
+if err != nil {
+ logger.Fatal(err)
+}
+
+myMotor, err := motor.FromProvider(machine, "my_motor")
+if err != nil {
+ logger.Fatal(err)
+}
+```
+
+In Go, each component package has a `FromProvider` function that returns the typed interface.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+The string `"my_sensor"` or `"my_motor"` is the name you gave the component in your machine configuration. If you named it something different, change the string to match.
+
+## Read from a sensor
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const readings = await sensor.getReadings();
+console.log(readings);
+// Example output: { temperature: 22.5, humidity: 45.2 }
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+final readings = await sensor.readings();
+print(readings);
+// Example output: {temperature: 22.5, humidity: 45.2}
+```
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+readings = await sensor.get_readings()
+print(readings)
+# Example output: {'temperature': 22.5, 'humidity': 45.2}
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+readings, err := mySensor.Readings(context.Background(), nil)
+if err != nil {
+ logger.Fatal(err)
+}
+fmt.Println(readings)
+// Example output: map[temperature:22.5 humidity:45.2]
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+The readings map contains whatever key-value pairs the sensor returns. The keys and types depend on the sensor model. For `fake:sensor`, the readings are generated test values.
+
+## Control a motor
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+// Set power (range: -1 to 1)
+await motor.setPower(0.5);
+
+// Stop
+await motor.stop();
+
+// Check if moving
+const moving = await motor.isMoving();
+console.log(`Motor is moving: ${moving}`);
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+// Set power (range: -1 to 1)
+await motor.setPower(0.5);
+
+// Stop
+await motor.stop();
+
+// Check if moving
+final moving = await motor.isMoving();
+print('Motor is moving: $moving');
+```
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+# Set power (range: -1 to 1)
+await motor.set_power(power=0.5)
+
+# Stop
+await motor.stop()
+
+# Check if moving
+moving = await motor.is_moving()
+print(f"Motor is moving: {moving}")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+// Set power (range: -1 to 1)
+err = myMotor.SetPower(context.Background(), 0.5, nil)
+if err != nil {
+ logger.Fatal(err)
+}
+
+// Stop
+err = myMotor.Stop(context.Background(), nil)
+if err != nil {
+ logger.Fatal(err)
+}
+
+// Check if moving
+moving, err := myMotor.IsMoving(context.Background())
+if err != nil {
+ logger.Fatal(err)
+}
+fmt.Printf("Motor is moving: %v\n", moving)
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+`setPower` takes a value from -1 (full reverse) to 1 (full forward). 0 is stopped. For `fake:motor`, the state changes are tracked internally but nothing physical moves. You can verify the state change by opening the Viam app's **CONTROL** tab for the same machine.
+
+## Other component types
+
+The pattern is the same for every component type. Get a typed client by name, then call that type's methods:
+
+| Component | Get client (Python) | Common methods |
+| --------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| Arm | `Arm.from_robot(robot=machine, name="x")` | `get_end_position`, `move_to_position`, `get_joint_positions`, `move_to_joint_positions`, `stop` |
+| Base | `Base.from_robot(robot=machine, name="x")` | `move_straight`, `spin`, `set_power`, `set_velocity`, `stop` |
+| Camera | `Camera.from_robot(robot=machine, name="x")` | `get_images`, `get_point_cloud`, `get_properties` |
+| Gripper | `Gripper.from_robot(robot=machine, name="x")` | `open`, `grab`, `stop`, `is_moving` |
+| Servo | `Servo.from_robot(robot=machine, name="x")` | `move`, `get_position`, `stop` |
+
+The method names follow the same naming convention in each language: Python uses `snake_case`, Go uses `CamelCase`, TypeScript uses `camelCase`, Flutter uses `camelCase`. See your SDK's reference for the exact signatures.
+
+## Next
+
+- [Stream video](/build-apps/tasks/stream-video/) for displaying camera feeds (TypeScript and Flutter WebRTC streaming, or `get_images` polling for Python and Go)
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for reconnection behavior when controlling actuators
+- [Connect to a machine](/build-apps/tasks/connect-to-machine/) if you have not set up a connection yet
diff --git a/docs/build-apps/tasks/handle-connection-state.md b/docs/build-apps/tasks/handle-connection-state.md
new file mode 100644
index 0000000000..8912ab1f77
--- /dev/null
+++ b/docs/build-apps/tasks/handle-connection-state.md
@@ -0,0 +1,263 @@
+---
+linkTitle: "Handle disconnection and reconnection"
+title: "Handle disconnection and reconnection"
+weight: 50
+layout: "docs"
+type: "docs"
+description: "Show a connection status indicator, react to reconnection, and rebuild your app's UI state after the SDK reconnects to a machine."
+date: "2026-04-10"
+---
+
+Show a connection status indicator in your app, react to connection events, and rebuild UI state after the SDK reconnects. The SDK reconnects the transport layer automatically; your app is responsible for rebuilding streams, timers, and anything else that depended on the old connection.
+
+For an explanation of how the SDK reconnects under the hood, see [Connection model](/build-apps/concepts/how-apps-connect/#reconnection).
+
+{{< alert title="Behavior change" color="caution" >}}
+In versions of the Viam TypeScript SDK prior to v0.69.0, `DISCONNECTED` fired on any connection drop, including transient network interruptions.
+From v0.69.0 onward, `DISCONNECTED` fires only on intentional close or when `noReconnect` is set.
+Transient drops emit `RECONNECTING` instead, followed by `CONNECTED` on success or `RECONNECTION_FAILED` when retries are exhausted.
+
+If your app listens for `DISCONNECTED` to detect network drops, listen for `RECONNECTING` instead.
+{{< /alert >}}
+
+## Prerequisites
+
+- A project with an active machine connection (see [Connect to a machine](/build-apps/tasks/connect-to-machine/))
+
+## TypeScript: subscribe to connection events
+
+The TypeScript SDK emits events on the `RobotClient` whenever the connection state changes. Subscribe to `connectionstatechange` to get a single handler for all state transitions:
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const machine = await VIAM.createRobotClient({
+ /* ... */
+});
+
+machine.on("connectionstatechange", (event) => {
+ const { eventType } = event as { eventType: VIAM.MachineConnectionEvent };
+ switch (eventType) {
+ case VIAM.MachineConnectionEvent.DIALING:
+ case VIAM.MachineConnectionEvent.CONNECTING:
+ console.log("Connecting...");
+ break;
+ case VIAM.MachineConnectionEvent.CONNECTED:
+ console.log("Connected");
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTING:
+ console.log("Connection dropped, reconnecting...");
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTION_FAILED:
+ console.log("Reconnection failed, giving up");
+ break;
+ case VIAM.MachineConnectionEvent.DISCONNECTING:
+ case VIAM.MachineConnectionEvent.DISCONNECTED:
+ console.log("Disconnected");
+ break;
+ }
+});
+```
+
+`MachineConnectionEvent` has seven values:
+
+| Value | When the SDK emits it |
+| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DIALING` | The SDK is dialing the initial connection. |
+| `CONNECTING` | The SDK is establishing the WebRTC or gRPC channel. |
+| `CONNECTED` | The connection is up and ready for calls. |
+| `RECONNECTING` | The connection dropped and the SDK is retrying with backoff. Replaces the immediate `DISCONNECTED` event for unintentional drops. |
+| `RECONNECTION_FAILED` | All reconnection attempts were exhausted. The event payload includes `error` and `attempts` fields. |
+| `DISCONNECTING` | The app initiated a disconnect (`disconnect()` or app shutdown). |
+| `DISCONNECTED` | The connection is closed and will not retry. Emitted when `noReconnect` is set, when the client was closed, or after intentional disconnect. |
+
+To listen for a specific state only, subscribe to that event type directly:
+
+```ts
+machine.on(VIAM.MachineConnectionEvent.DISCONNECTED, () => {
+ console.log("Disconnected from machine");
+});
+```
+
+### Show a status indicator in the browser
+
+A minimal connection indicator in a vanilla browser app:
+
+```ts
+const statusEl = document.getElementById("status") as HTMLElement;
+
+function setStatus(text: string, color: string) {
+ statusEl.textContent = text;
+ statusEl.style.color = color;
+}
+
+machine.on("connectionstatechange", (event) => {
+ const { eventType } = event as { eventType: VIAM.MachineConnectionEvent };
+ switch (eventType) {
+ case VIAM.MachineConnectionEvent.CONNECTED:
+ setStatus("Connected", "green");
+ break;
+ case VIAM.MachineConnectionEvent.DIALING:
+ case VIAM.MachineConnectionEvent.CONNECTING:
+ setStatus("Connecting...", "orange");
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTING:
+ setStatus("Reconnecting...", "orange");
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTION_FAILED:
+ case VIAM.MachineConnectionEvent.DISCONNECTING:
+ case VIAM.MachineConnectionEvent.DISCONNECTED:
+ setStatus("Disconnected", "red");
+ break;
+ }
+});
+```
+
+In React, Vue, or Svelte, set a reactive state variable inside the handler instead of mutating the DOM directly.
+
+## Flutter: check `isConnected`
+
+The Flutter SDK does not expose a connection-event API. Check `robot.isConnected` to know the current state:
+
+```dart
+if (robot.isConnected) {
+ // Connected
+} else {
+ // Not connected
+}
+```
+
+`RobotClient` runs a background connection check on a timer (default every 10 seconds) and attempts reconnection on its own if the check fails. You can tune these intervals through `RobotClientOptions`:
+
+```dart
+final options = RobotClientOptions.withApiKey(apiKeyId, apiKey);
+options.checkConnectionInterval = 5; // seconds between connection checks
+options.attemptReconnectInterval = 2; // seconds between reconnection attempts
+
+final robot = await RobotClient.atAddress(address, options);
+```
+
+Setting either interval to `0` disables the corresponding behavior.
+
+### Show a status indicator in Flutter
+
+Poll `isConnected` on a timer to drive a UI indicator:
+
+```dart
+class ConnectionIndicator extends StatefulWidget {
+ final RobotClient robot;
+ const ConnectionIndicator({super.key, required this.robot});
+
+ @override
+ State createState() => _ConnectionIndicatorState();
+}
+
+class _ConnectionIndicatorState extends State {
+ Timer? _timer;
+ bool _connected = true;
+
+ @override
+ void initState() {
+ super.initState();
+ _timer = Timer.periodic(const Duration(seconds: 2), (_) {
+ setState(() {
+ _connected = widget.robot.isConnected;
+ });
+ });
+ }
+
+ @override
+ void dispose() {
+ _timer?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ children: [
+ Icon(Icons.circle, color: _connected ? Colors.green : Colors.red, size: 12),
+ const SizedBox(width: 8),
+ Text(_connected ? 'Connected' : 'Disconnected'),
+ ],
+ );
+ }
+}
+```
+
+Adjust the `Duration(seconds: 2)` to match how quickly you want the UI to reflect state changes. A shorter interval gives faster feedback but uses more CPU.
+
+## Python and Go
+
+The Python and Go SDKs handle reconnection internally with configurable intervals, similar to Flutter. Both provide a connection check and automatic reconnection.
+
+**Python:**
+
+```python
+# Connection check and reconnection are configured through RobotClient.Options
+opts = RobotClient.Options.with_api_key(api_key, api_key_id)
+opts.check_connection_interval = 10 # seconds between checks (default 10)
+opts.attempt_reconnect_interval = 1 # seconds between reconnect attempts (default 1)
+```
+
+The Python SDK does not expose a connection-event API. For long-running services, wrap SDK calls in try/except blocks to catch connection errors and log them.
+
+**Go:**
+
+```go
+// Connection options are set through RobotClientOption functions
+machine, err := client.New(
+ ctx, address, logger,
+ client.WithDialOptions(rpc.WithEntityCredentials(apiKeyID, creds)),
+ client.WithCheckConnectedEvery(10 * time.Second),
+)
+```
+
+The Go SDK reconnects automatically. For long-running services, check errors returned from individual method calls. A `codes.Unavailable` gRPC status indicates the connection is down; the SDK will attempt to reconnect on the next call.
+
+## Rebuild UI state after reconnection
+
+The SDK reconnects the transport layer automatically, but anything your app built on top of the old connection does not resume automatically. This is the single most important pattern for apps that run through network drops:
+
+- **Camera streams** — the stream is torn down when the connection drops. After reconnection, call `getStream` again and re-attach the stream to your video element.
+- **Live sensor polling** — if you were polling `sensor.getReadings()` on a timer, the timer may still be running but the calls will have failed during the outage. Cancel and restart the timer on reconnect, or catch the errors and let the timer naturally resume on reconnection.
+- **Operation handles** — any operation IDs, session IDs, or other handles from before the reconnection are no longer valid. Drop them.
+- **UI state derived from the connection** — lists of resources, last-known readings, and similar cached data may be stale. Re-fetch after reconnection.
+
+A concrete pattern in TypeScript: when the connection handler reaches `CONNECTED` after having seen `RECONNECTING`, call a `rebuildAfterReconnect()` function that restarts streams and polling:
+
+```ts
+let wasReconnecting = false;
+
+machine.on("connectionstatechange", async (event) => {
+ const { eventType } = event as { eventType: VIAM.MachineConnectionEvent };
+ switch (eventType) {
+ case VIAM.MachineConnectionEvent.RECONNECTING:
+ wasReconnecting = true;
+ break;
+ case VIAM.MachineConnectionEvent.CONNECTED:
+ if (wasReconnecting) {
+ wasReconnecting = false;
+ await rebuildAfterReconnect();
+ }
+ break;
+ case VIAM.MachineConnectionEvent.RECONNECTION_FAILED:
+ case VIAM.MachineConnectionEvent.DISCONNECTED:
+ // Reset the flag so a later fresh connect is not treated as a reconnect.
+ wasReconnecting = false;
+ break;
+ }
+});
+
+async function rebuildAfterReconnect() {
+ // Recreate camera streams, restart polling, re-fetch resource names, etc.
+}
+```
+
+Use `RECONNECTING` as the trigger rather than `DISCONNECTED`. The SDK emits `RECONNECTING` as soon as it loses an unintentional connection and starts retrying; `DISCONNECTED` is now only emitted for closed clients or when `noReconnect` is set. Clear `wasReconnecting` on `RECONNECTION_FAILED` and `DISCONNECTED` so that a later reconnect attempt (for example, after the user manually reconnects) does not incorrectly trigger `rebuildAfterReconnect()` on its first `CONNECTED` event. Listen for `RECONNECTION_FAILED` separately if you want to surface the give-up state to the user.
+
+## Next
+
+- [Stream video](/build-apps/tasks/stream-video/) for the camera stream rebuild pattern in detail
+- [Query captured data](/build-apps/tasks/query-data/) for polling live data from an app
+- [Connection model](/build-apps/concepts/how-apps-connect/#reconnection) for a deeper explanation of the reconnection behavior
diff --git a/docs/build-apps/tasks/query-data.md b/docs/build-apps/tasks/query-data.md
new file mode 100644
index 0000000000..d7826439f3
--- /dev/null
+++ b/docs/build-apps/tasks/query-data.md
@@ -0,0 +1,312 @@
+---
+linkTitle: "Query captured data"
+title: "Query captured data"
+weight: 70
+layout: "docs"
+type: "docs"
+description: "Query sensor and binary data from a client app using SQL and MQL through DataClient."
+date: "2026-04-10"
+---
+
+Query data that your Viam machines have captured and synced to the cloud, from inside a client app. This page covers the SDK calls for running SQL and MQL queries from app code. For the query languages themselves (schema, operators, examples), see [Query data](/data/query-data/) in the data section.
+
+## Prerequisites
+
+- A project with a cloud connection (see [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/))
+- The organization ID that owns the data you want to query
+- Data capture configured on at least one machine so there is something to query (see [Capture and sync data](/data/capture-sync/capture-and-sync-data/))
+
+## Run a SQL query
+
+SQL is the simplest entry point. The query runs against your organization's captured tabular data.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const orgId = "your-organization-id";
+
+const rows = await client.dataClient.tabularDataBySQL(
+ orgId,
+ `SELECT component_name, time_received, data
+ FROM readings
+ WHERE component_name = 'temperature_sensor'
+ ORDER BY time_received DESC
+ LIMIT 20`,
+);
+
+console.log(`Got ${rows.length} rows`);
+for (const row of rows) {
+ console.log(row);
+}
+```
+
+`tabularDataBySQL` returns an array of row objects. The shape of each row depends on your query's `SELECT` list.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+final orgId = 'your-organization-id';
+
+final rows = await viam.dataClient.tabularDataBySql(
+ orgId,
+ '''
+ SELECT component_name, time_received, data
+ FROM readings
+ WHERE component_name = 'temperature_sensor'
+ ORDER BY time_received DESC
+ LIMIT 20
+ ''',
+);
+
+print('Got ${rows.length} rows');
+for (final row in rows) {
+ print(row);
+}
+```
+
+`tabularDataBySql` returns a `List>` where each map is one row.
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+org_id = "your-organization-id"
+
+rows = await client.data_client.tabular_data_by_sql(
+ org_id,
+ """SELECT component_name, time_received, data
+ FROM readings
+ WHERE component_name = 'temperature_sensor'
+ ORDER BY time_received DESC
+ LIMIT 20"""
+)
+
+print(f"Got {len(rows)} rows")
+for row in rows:
+ print(row)
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+orgID := "your-organization-id"
+
+rows, err := client.DataClient().TabularDataBySQL(
+ context.Background(),
+ orgID,
+ "SELECT component_name, time_received, data FROM readings WHERE component_name = 'temperature_sensor' ORDER BY time_received DESC LIMIT 20",
+)
+if err != nil {
+ logger.Fatal(err)
+}
+
+fmt.Printf("Got %d rows\n", len(rows))
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+## Run an MQL query
+
+MQL (MongoDB Query Language) supports aggregation pipelines: `$match`, `$group`, `$project`, and so on. Use MQL when you need to aggregate data across machines or compute derived values.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const orgId = "your-organization-id";
+
+const pipeline = [
+ {
+ $match: {
+ component_name: "air_quality_sensor",
+ time_received: { $gte: new Date(Date.now() - 3600 * 1000) },
+ },
+ },
+ {
+ $group: {
+ _id: "$robot_id",
+ avg_pm_25: { $avg: "$data.readings.pm_2_5" },
+ max_pm_25: { $max: "$data.readings.pm_2_5" },
+ sample_count: { $sum: 1 },
+ },
+ },
+];
+
+const results = await client.dataClient.tabularDataByMQL(orgId, pipeline);
+console.log(results);
+```
+
+The SDK serializes plain JavaScript objects into BSON automatically. You can also pass pre-serialized `Uint8Array[]` if you are generating the pipeline elsewhere.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+final orgId = 'your-organization-id';
+
+final pipeline = [
+ {
+ '\$match': {
+ 'component_name': 'air_quality_sensor',
+ 'time_received': {
+ '\$gte': DateTime.now().subtract(const Duration(hours: 1)),
+ },
+ },
+ },
+ {
+ '\$group': {
+ '_id': '\$robot_id',
+ 'avg_pm_25': {'\$avg': '\$data.readings.pm_2_5'},
+ 'max_pm_25': {'\$max': '\$data.readings.pm_2_5'},
+ 'sample_count': {'\$sum': 1},
+ },
+ },
+];
+
+final results = await viam.dataClient.tabularDataByMql(orgId, pipeline);
+print(results);
+```
+
+Flutter's `tabularDataByMql` also accepts either `List>` (auto-serialized) or `List` (pre-serialized with `BsonCodec.serialize`).
+
+Note that `$` is a special character in Dart string interpolation, so MQL operators must be escaped as `\$match`, `\$group`, and so on inside string literals.
+
+{{% /tab %}}
+{{% tab name="Python" %}}
+
+```python
+from datetime import datetime, timedelta
+
+org_id = "your-organization-id"
+
+pipeline = [
+ {
+ "$match": {
+ "component_name": "air_quality_sensor",
+ "time_received": {"$gte": datetime.now() - timedelta(hours=1)},
+ }
+ },
+ {
+ "$group": {
+ "_id": "$robot_id",
+ "avg_pm_25": {"$avg": "$data.readings.pm_2_5"},
+ "max_pm_25": {"$max": "$data.readings.pm_2_5"},
+ "sample_count": {"$sum": 1},
+ }
+ },
+]
+
+results = await client.data_client.tabular_data_by_mql(org_id, pipeline)
+print(results)
+```
+
+Python's `tabular_data_by_mql` accepts either a list of dicts (auto-serialized) or pre-serialized BSON bytes.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+orgID := "your-organization-id"
+
+pipeline := []map[string]interface{}{
+ {
+ "$match": map[string]interface{}{
+ "component_name": "air_quality_sensor",
+ },
+ },
+ {
+ "$group": map[string]interface{}{
+ "_id": "$robot_id",
+ "avg_pm_25": map[string]interface{}{"$avg": "$data.readings.pm_2_5"},
+ "sample_count": map[string]interface{}{"$sum": 1},
+ },
+ },
+}
+
+results, err := client.DataClient().TabularDataByMQL(
+ context.Background(), orgID, pipeline, nil,
+)
+if err != nil {
+ logger.Fatal(err)
+}
+fmt.Println(results)
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+## SQL or MQL, when to use each
+
+- **SQL** for simple selects, filters, and joins against captured tabular data. Familiar syntax, works well for dashboards that show raw or lightly filtered data.
+- **MQL** for aggregation pipelines: averages, windowed sums, grouping across components, computing derived fields. Required for fleet-wide aggregations that SQL cannot express cleanly.
+
+The two query languages run against the same underlying data. Pick the one that matches how you want to think about the query, not based on any performance difference.
+
+## Refresh data on a timer
+
+Dashboards typically refresh on a timer. Neither the TypeScript nor the Flutter SDK provides a subscription API for live data: you poll on a `setInterval` or `Timer.periodic` and update your UI when the results come back.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const intervalId = setInterval(async () => {
+ try {
+ const rows = await client.dataClient.tabularDataBySQL(orgId, query);
+ updateDashboard(rows);
+ } catch (err) {
+ console.error("Query failed:", err);
+ }
+}, 10_000);
+
+// Later, when the component unmounts or the user navigates away:
+clearInterval(intervalId);
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+```dart
+Timer.periodic(const Duration(seconds: 10), (timer) async {
+ try {
+ final rows = await viam.dataClient.tabularDataBySql(orgId, query);
+ setState(() {
+ _rows = rows;
+ });
+ } catch (e) {
+ print('Query failed: $e');
+ }
+});
+```
+
+Cancel the timer in your `State.dispose()` method.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+Pick a refresh interval that balances UI freshness against query cost. Every query is a network round trip to the Viam cloud and a database query against your captured data. For a dashboard showing minute-scale aggregations, refresh every 10-30 seconds. For near-real-time displays, refresh every 1-5 seconds but expect higher data transfer.
+
+## Query binary data
+
+The same `DataClient` can filter binary data (images, point clouds) captured by the data manager. Use `binaryDataByFilter` with a filter specification:
+
+```ts
+const binaries = await client.dataClient.binaryDataByFilter({
+ componentName: "front_camera",
+ startTime: new Date("2026-04-01T00:00:00Z"),
+ endTime: new Date("2026-04-02T00:00:00Z"),
+ tags: ["training"],
+});
+```
+
+Binary data responses include metadata and a URL to download the file. See the [Data API reference](/reference/apis/data-client/) for the full request shape.
+
+## Next
+
+- [Query data](/data/query-data/) for the SQL and MQL query languages, the schema, and operator reference
+- [Connect to the Viam cloud](/build-apps/tasks/connect-to-cloud/) for setting up the cloud connection that `dataClient` requires
+- [Data API reference](/reference/apis/data-client/) for the full `DataClient` method list
diff --git a/docs/build-apps/tasks/stream-video.md b/docs/build-apps/tasks/stream-video.md
new file mode 100644
index 0000000000..654a4031e2
--- /dev/null
+++ b/docs/build-apps/tasks/stream-video.md
@@ -0,0 +1,151 @@
+---
+linkTitle: "Stream video"
+title: "Stream video"
+weight: 60
+layout: "docs"
+type: "docs"
+description: "Display live camera feeds in a client app. Covers single-camera and multi-camera streaming, resolution tradeoffs, and bandwidth considerations."
+date: "2026-04-10"
+---
+
+Display a live camera feed from a Viam machine in your client app. The TypeScript and Flutter SDKs use WebRTC for video streaming, which gives low latency suitable for teleoperation. Python, Go, and C++ access camera data through single-frame methods (`get_images`, `GetImages`) rather than WebRTC streams; for live video in those languages, poll `get_images` on a timer. This page focuses on the WebRTC streaming path available in TypeScript and Flutter.
+
+## Prerequisites
+
+- A project with an active machine connection (see [Connect to a machine](/build-apps/tasks/connect-to-machine/))
+- A camera component configured on the machine. Any camera model will work, including `fake:camera` for testing without real hardware.
+
+## Stream one camera
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+Create a `StreamClient` from your `RobotClient` and call `getStream(name)` to get a `MediaStream`. Attach it to an HTML `` element:
+
+```html
+
+```
+
+```ts
+import * as VIAM from "@viamrobotics/sdk";
+
+const streamClient = new VIAM.StreamClient(machine);
+const mediaStream = await streamClient.getStream("my_camera");
+
+const videoEl = document.getElementById("camera") as HTMLVideoElement;
+videoEl.srcObject = mediaStream;
+```
+
+`getStream` waits up to 5 seconds for the first video track to arrive, then resolves with a `MediaStream`. The `` element must have `autoplay`, `playsinline`, and `muted` attributes to play a `MediaStream` without user interaction in most browsers.
+
+To stop a stream, call `remove` with the camera name:
+
+```ts
+await streamClient.remove("my_camera");
+```
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+The Flutter SDK ships a `ViamCameraStreamView` widget that handles the WebRTC renderer internally. Obtain a `Camera` component and a `StreamClient` from your `RobotClient`, then pass both to the widget:
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:viam_sdk/viam_sdk.dart';
+import 'package:viam_sdk/widgets.dart';
+
+class CameraView extends StatelessWidget {
+ final RobotClient robot;
+ const CameraView({super.key, required this.robot});
+
+ @override
+ Widget build(BuildContext context) {
+ final camera = Camera.fromRobot(robot, 'my_camera');
+ final streamClient = robot.getStream('my_camera');
+ return ViamCameraStreamView(
+ camera: camera,
+ streamClient: streamClient,
+ );
+ }
+}
+```
+
+`ViamCameraStreamView` manages the underlying `RTCVideoRenderer`, tears it down in `dispose()`, and displays an error state if the stream fails.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+## Stream multiple cameras
+
+Multiple cameras work the same way as one: call `getStream` (TypeScript) or `robot.getStream` plus `ViamCameraStreamView` (Flutter) once per camera name.
+
+{{< tabs >}}
+{{% tab name="TypeScript" %}}
+
+```ts
+const streamClient = new VIAM.StreamClient(machine);
+
+const frontStream = await streamClient.getStream("front_camera");
+const rearStream = await streamClient.getStream("rear_camera");
+
+(document.getElementById("front") as HTMLVideoElement).srcObject = frontStream;
+(document.getElementById("rear") as HTMLVideoElement).srcObject = rearStream;
+```
+
+A single `StreamClient` can manage multiple streams. You do not need one client per camera.
+
+{{% /tab %}}
+{{% tab name="Flutter" %}}
+
+The Flutter SDK also ships a `ViamMultiCameraStreamView` widget for multi-camera layouts, which handles the stream management for several cameras at once. See the [Flutter SDK reference](https://flutter.viam.dev/) for the widget's parameters.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Hardware and bandwidth limits
+
+Streaming more than two or three cameras at once from the same machine is unreliable on typical hardware. Reported failure modes from practitioners:
+
+- **USB bandwidth ceilings.** Stacking several USB cameras on the same host saturates the bus before the WebRTC encoder does. Cameras appear to connect but deliver corrupt or empty frames.
+- **WebRTC peer connection limits.** Some host hardware cannot negotiate more than three simultaneous WebRTC video tracks. The third or fourth `getStream` call hangs or times out.
+- **Cellular bandwidth cost.** A single 720p camera stream can use 1-3 Mbps sustained. On a cellular deployment, two or three simultaneous streams can burn through a monthly data cap in days.
+
+If you need more than two cameras in one UI, drop resolution first, then consider whether you can show one camera at a time and let the user switch.
+
+## Resolution and bandwidth
+
+Lower resolutions use less bandwidth and CPU on both ends. The TypeScript SDK exposes resolution control on `StreamClient`:
+
+```ts
+// See what resolutions the camera advertises
+const options = await streamClient.getOptions("my_camera");
+console.log(options);
+
+// Set a specific resolution (width, height in pixels)
+await streamClient.setOptions("my_camera", 640, 480);
+
+// Reset to the camera's default
+await streamClient.resetOptions("my_camera");
+```
+
+`setOptions` takes effect on the next `getStream` call for that camera. If you change the resolution while a stream is active, remove and re-add the stream to apply the change.
+
+Practical guidance:
+
+- **Teleoperation of a vehicle or arm.** Prioritize framerate and latency over resolution. 640x480 at 30 fps is usually better than 1920x1080 at 5 fps.
+- **Inspection dashboards where the user stares at one feed.** Higher resolution helps. Use the camera's native resolution unless bandwidth is a hard constraint.
+- **Multi-camera fleet overviews.** Drop every stream to the lowest useful resolution. The user is scanning, not inspecting.
+
+## Reconnection behavior
+
+The TypeScript SDK's `StreamClient` tracks open streams and automatically re-adds them when the `RobotClient` reconnects. Your app does not need to re-call `getStream`, but the HTML `` element may still need its `srcObject` reattached because the old `MediaStream` object becomes invalid. A simple pattern is to re-run the `getStream` and `videoEl.srcObject = mediaStream` assignment inside your connection-state handler.
+
+In the Flutter SDK, `StreamClient` does not explicitly re-add streams on reconnect. If a stream becomes inactive after a disconnection, tear down the `ViamCameraStreamView` and recreate it, or listen for connection-state changes and restart the stream manually.
+
+See [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for the connection-event pattern.
+
+## Next
+
+- [Handle disconnection and reconnection](/build-apps/tasks/handle-connection-state/) for the rebuild-after-reconnect pattern
+- [Query captured data](/build-apps/tasks/query-data/) for reading captured data alongside live streams
+- [Camera component reference](/hardware/common-components/add-a-camera/) for per-model configuration
diff --git a/docs/build-apps/tasks/test-locally.md b/docs/build-apps/tasks/test-locally.md
new file mode 100644
index 0000000000..7f6593434c
--- /dev/null
+++ b/docs/build-apps/tasks/test-locally.md
@@ -0,0 +1,144 @@
+---
+linkTitle: "Test against a local machine"
+title: "Test against a local machine"
+weight: 80
+layout: "docs"
+type: "docs"
+description: "Develop a Viam client app against a real machine without deploying. Covers direct local-network connections and the viam module local-app-testing CLI."
+date: "2026-04-10"
+---
+
+Develop your Viam client app against a real machine without deploying to production on every change. There are two ways: open a direct connection to the machine from your local dev server, or use the `viam module local-app-testing` CLI to emulate the Viam Applications hosting environment.
+
+For the CLI command name: `viam module local-app-testing` is grouped under `viam module` because Viam Applications are distributed through the same module registry. It has nothing to do with building server-side modules. Treat the `module` in the command name as an artifact of the registry plumbing.
+
+## Which approach to use
+
+- **Direct connection** if your app does not need cookie-based credential injection. You use the same connection code you would use in production, with the credentials in a local `.env` file. Simplest for single-machine browser apps and for Flutter apps.
+- **`local-app-testing` CLI** if your app is a hosted Viam Application that reads credentials from browser cookies. The CLI emulates the cookie injection that Viam Applications does in production, so your app code can use the same cookie-reading logic in development.
+
+## Direct connection
+
+Run your local dev server with credentials in a `.env` file. Your app calls `createRobotClient` or `RobotClient.atAddress` with the credentials from the file.
+
+See [App scaffolding](/build-apps/setup/) for the platform-specific setup pages, which all use this pattern in their connection verification step. The setup pages are the canonical reference for this approach.
+
+### Local-network connections
+
+If the machine is on the same network as your dev server, you can skip the cloud signaling service and connect directly over the local network. Restart `viam-server` with the `-no-tls` flag, then set `signalingAddress` to the machine's local address instead of `https://app.viam.com:443`:
+
+```ts
+const machine = await VIAM.createRobotClient({
+ host: "my-robot-main.xxxx.viam.cloud",
+ credentials: {
+ type: "api-key",
+ authEntity: "",
+ payload: "",
+ },
+ signalingAddress: "http://my-robot.local:8080",
+});
+```
+
+Local-network connections eliminate the cloud round trip and are useful for latency-sensitive testing. See the [connectivity reference](/reference/sdks/connectivity/#connect-over-local-network-or-offline) for the exact `viam-server` flags and local hostname setup.
+
+## `viam module local-app-testing` CLI
+
+The `local-app-testing` CLI proxies your local dev server and injects the same cookies that the Viam Applications hosting platform injects in production. This lets you test an app that reads credentials from cookies without deploying to the registry on every change.
+
+The CLI has two modes, matching the two Viam Application types:
+
+- **Single-machine mode** (pass `--machine-id`). The CLI fetches an API key for the specified machine from the Viam backend and writes a machine-specific cookie that your app reads.
+- **Multi-machine mode** (omit `--machine-id`). The CLI uses the user access token from your current `viam login` session and writes it as a cookie, so your app sees the same credential shape it would see in a deployed multi-machine Viam Application.
+
+### Start your dev server
+
+Run your app's dev server as you normally would. For a Vite-based web app:
+
+```sh {class="command-line" data-prompt="$"}
+npx vite
+```
+
+Vite prints a URL, typically `http://localhost:5173`. Leave this running.
+
+### Start the local-app-testing server
+
+In a second terminal, run the CLI:
+
+{{< tabs >}}
+{{% tab name="Single-machine" %}}
+
+```sh {class="command-line" data-prompt="$"}
+viam login
+viam module local-app-testing \
+ --app-url http://localhost:5173 \
+ --machine-id YOUR-MACHINE-ID
+```
+
+Get `YOUR-MACHINE-ID` from the machine's page in the Viam app. The CLI:
+
+1. Fetches an API key for the machine from the Viam backend.
+2. Fetches the machine's FQDN from its main part.
+3. Starts an HTTP server on `http://localhost:8012`.
+4. Opens your browser to `http://localhost:8012/start`, which sets the cookies and redirects to your dev server through the proxy.
+
+{{% /tab %}}
+{{% tab name="Multi-machine" %}}
+
+```sh {class="command-line" data-prompt="$"}
+viam login
+viam module local-app-testing --app-url http://localhost:5173
+```
+
+Without `--machine-id`, the CLI runs in multi-machine mode and uses the access token from your `viam login` session. Do not pass an API key profile for multi-machine mode; the CLI needs a user access token, not a service-identity API key. The CLI:
+
+1. Reads your current access token from the local CLI config.
+2. Starts an HTTP server on `http://localhost:8012`.
+3. Opens your browser to `http://localhost:8012/start`, which sets the user-token cookie and redirects to your dev server.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Develop against the running machine
+
+Edit your source files in the dev server project. Hot reload, browser refresh, and all the development features of your chosen tooling work the same way they would without the proxy. The cookies persist across reloads, so you do not need to re-run the CLI after every source change.
+
+Stop the local-app-testing server with `Ctrl+C` when you are done. The dev server can keep running.
+
+## Firefox WebRTC localhost workaround
+
+Firefox blocks WebRTC connections from `localhost` due to a network interface enumeration restriction. If you use Firefox for development, you have two options.
+
+The simplest option is to use Chrome, Edge, or another Chromium-based browser for local testing. WebRTC works from `localhost` in those browsers without any configuration.
+
+If you need Firefox specifically, add a local hostname to your `/etc/hosts` file and run your dev server bound to that hostname instead of `localhost`:
+
+```sh {class="command-line" data-prompt="$"}
+sudo bash -c 'echo "127.0.0.1 dev.local" >> /etc/hosts'
+```
+
+Then start your dev server with the hostname. For Vite:
+
+```sh {class="command-line" data-prompt="$"}
+npx vite --host dev.local
+```
+
+Open `http://dev.local:5173` in Firefox. WebRTC works because the URL is no longer `localhost`.
+
+To remove the hostname later:
+
+```sh {class="command-line" data-prompt="$"}
+sudo sed -i '' '/dev.local/d' /etc/hosts
+```
+
+(On Linux, drop the empty `''` argument to `sed -i`.)
+
+## Troubleshooting
+
+- **Port 8012 already in use.** The CLI hardcodes port 8012. If another process owns it, stop that process first.
+- **"No access token found" in multi-machine mode.** Run `viam login` (not `viam login api-key`) to obtain a user access token. Service-identity API keys do not grant user-level access.
+- **Cookies not set in the browser.** The CLI redirects the browser to `/start` on first run, which is where the cookies are written. If you navigate directly to a proxied path before visiting `/start`, the cookies are missing. Close the tab and reopen from the terminal link, or navigate to `http://localhost:8012/` manually.
+
+## Next
+
+- [Deploy a Viam application](/build-apps/hosting/deploy/) for packaging and uploading your app once local testing is complete
+- [Connectivity reference](/reference/sdks/connectivity/) for local-network and offline connection details
diff --git a/docs/build-modules/_index.md b/docs/build-modules/_index.md
new file mode 100644
index 0000000000..d998147d99
--- /dev/null
+++ b/docs/build-modules/_index.md
@@ -0,0 +1,16 @@
+---
+linkTitle: "Build and deploy modules"
+title: "Build and deploy modules"
+weight: 60
+layout: "docs"
+type: "docs"
+no_list: true
+description: "Understand the two kinds of modules, then write, test, and deploy your own."
+manualLink: "/build-modules/overview/"
+aliases:
+ - /build/development/
+ - /development/
+ - /hardware-components/hardware-to-logic/
+ - /hardware/hardware-to-logic/
+ - /build/
+---
diff --git a/docs/build-modules/dependencies.md b/docs/build-modules/dependencies.md
new file mode 100644
index 0000000000..de8f03a745
--- /dev/null
+++ b/docs/build-modules/dependencies.md
@@ -0,0 +1,247 @@
+---
+title: "Access machine resources from within a module"
+linkTitle: "Module dependencies"
+weight: 36
+layout: "docs"
+type: "docs"
+description: "From within a modular resource, you can access other machine resources using dependencies."
+aliases:
+date: "2025-11-11"
+---
+
+From within a modular resource, you can access other machine {{< glossary_tooltip term_id="resource" text="resources" >}} using dependencies.
+For background on required and optional dependencies, see the
+[overview](/build-modules/overview/#dependencies).
+
+## The dependency pattern
+
+Every dependency follows three steps: declare it in validation, resolve it in your constructor or reconfigure method, then call its API methods.
+
+The examples below show a base that depends on two motors -- a required left motor and an optional right motor (for a base that can operate in single-motor mode).
+
+### 1. Declare dependencies in validation
+
+Dependency names come from your resource's configuration attributes, keeping the module flexible:
+
+```json {class="line-numbers linkable-line-numbers"}
+{
+ "name": "my-base",
+ "api": "rdk:component:base",
+ "model": "myorg:mymodule:mybase",
+ "attributes": {
+ "left_motor": "motor-1",
+ "right_motor": "motor-2"
+ }
+}
+```
+
+Your validation method parses these names and returns them as required or optional dependencies:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python {class="line-numbers linkable-line-numbers"}
+@classmethod
+def validate_config(
+ cls, config: ComponentConfig
+) -> Tuple[Sequence[str], Sequence[str]]:
+ req_deps = []
+ opt_deps = []
+ fields = config.attributes.fields
+
+ # Required dependency
+ if "left_motor" not in fields:
+ raise Exception("missing required left_motor attribute")
+ req_deps.append(fields["left_motor"].string_value)
+
+ # Optional dependency
+ if "right_motor" in fields:
+ opt_deps.append(fields["right_motor"].string_value)
+
+ return req_deps, opt_deps
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Define your config struct with fields for each dependency name:
+
+```go {class="line-numbers linkable-line-numbers"}
+type Config struct {
+ LeftMotor string `json:"left_motor"`
+ RightMotor string `json:"right_motor"`
+}
+
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ // Required dependency
+ if cfg.LeftMotor == "" {
+ return nil, nil,
+ resource.NewConfigValidationFieldRequiredError(
+ path, "left_motor")
+ }
+ reqDeps := []string{cfg.LeftMotor}
+
+ // Optional dependency
+ var optDeps []string
+ if cfg.RightMotor != "" {
+ optDeps = append(optDeps, cfg.RightMotor)
+ }
+
+ return reqDeps, optDeps, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 2. Resolve dependencies
+
+In Python, resolve dependencies in the `reconfigure` method. In Go, resolve them in your constructor (or `Reconfigure` method if you are not using `AlwaysRebuild`).
+
+Use the dependency name to look up the resource, then cast it to the correct type.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python {class="line-numbers linkable-line-numbers"}
+from typing import cast
+from viam.components.motor import Motor
+
+
+def reconfigure(
+ self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]
+):
+ fields = config.attributes.fields
+
+ # Required dependency -- direct lookup
+ left_name = fields["left_motor"].string_value
+ self.left = cast(
+ Motor,
+ dependencies[Motor.get_resource_name(left_name)])
+
+ # Optional dependency -- use .get() and handle None
+ self.right = None
+ if "right_motor" in fields:
+ right_name = fields["right_motor"].string_value
+ right_resource = dependencies.get(
+ Motor.get_resource_name(right_name))
+ if right_resource is not None:
+ self.right = cast(Motor, right_resource)
+
+ return super().reconfigure(config, dependencies)
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go {class="line-numbers linkable-line-numbers"}
+import (
+ "go.viam.com/rdk/components/motor"
+)
+
+func newMyBase(ctx context.Context, deps resource.Dependencies,
+ conf resource.Config, logger logging.Logger,
+) (base.Base, error) {
+ baseConfig, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return nil, err
+ }
+
+ b := &myBase{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ }
+
+ // Required dependency
+ b.left, err = motor.FromProvider(deps, baseConfig.LeftMotor)
+ if err != nil {
+ return nil, err
+ }
+
+ // Optional dependency -- check config, ignore error
+ if baseConfig.RightMotor != "" {
+ b.right, err = motor.FromProvider(
+ deps, baseConfig.RightMotor)
+ if err != nil {
+ logger.Infow("right motor not available, "+
+ "running in single-motor mode",
+ "error", err)
+ }
+ }
+
+ return b, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+{{% alert title="Note" color="note" %}}
+Go modules that use `resource.AlwaysRebuild` resolve dependencies in the constructor, which runs on every reconfiguration.
+If you need to maintain state across reconfigurations, see [Handle reconfiguration](/build-modules/write-a-driver-module/#6-handle-reconfiguration-optional).
+{{% /alert %}}
+
+### 3. Use dependencies
+
+Once resolved, call API methods on your dependencies like any other resource:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python {class="line-numbers linkable-line-numbers"}
+async def set_power(
+ self,
+ linear: Vector3,
+ angular: Vector3,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs
+):
+ await self.left.set_power(linear.y + angular.z)
+ if self.right:
+ await self.right.set_power(linear.y - angular.z)
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go {class="line-numbers linkable-line-numbers"}
+func (b *myBase) SetPower(
+ ctx context.Context,
+ linear, angular r3.Vector,
+ extra map[string]interface{},
+) error {
+ err := b.left.SetPower(
+ ctx, linear.Y+angular.Z, extra)
+ if err != nil {
+ return err
+ }
+ if b.right != nil {
+ return b.right.SetPower(
+ ctx, linear.Y-angular.Z, extra)
+ }
+ return nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+{{% alert title="Accessing built-in services" color="tip" %}}
+Some services like the motion service are available by default as part of `viam-server` even though they don't appear in your machine config. To depend on one, use its full resource name in your validation method:
+
+**Python:** `req_deps.append("rdk:service:motion/builtin")`
+
+**Go:** `deps := []string{motion.Named("builtin").String()}`
+
+Then resolve it the same way as any other dependency.
+{{% /alert %}}
+
+## What's next
+
+- [Write a Driver Module](/build-modules/write-a-driver-module/) -- full walkthrough including dependency handling in context.
+- [Write a Logic Module](/build-modules/write-a-logic-module/) -- build a module that coordinates multiple resources.
+- [Access platform APIs](/build-modules/platform-apis/) -- access fleet management, data, and ML training APIs from within a module.
+- For full examples, see the [Desk Safari tutorial](/operate/hello-world/tutorial-desk-safari/) or [complex module examples on GitHub](https://github.com/viamrobotics/viam-python-sdk/tree/main/examples/complex_module/src).
diff --git a/docs/build-modules/deploy-a-module.md b/docs/build-modules/deploy-a-module.md
new file mode 100644
index 0000000000..0aa5c7c3b3
--- /dev/null
+++ b/docs/build-modules/deploy-a-module.md
@@ -0,0 +1,547 @@
+---
+linkTitle: "Deploy a module"
+title: "Deploy a module"
+weight: 30
+layout: "docs"
+type: "docs"
+description: "Package, upload, and distribute a module through the Viam registry."
+date: "2025-01-30"
+aliases:
+ - /build/development/deploy-a-module/
+ - /development/deploy-a-module/
+ - /extend/modular-resources/upload/
+ - /modular-resources/upload/
+ - /registry/upload/
+ - /how-tos/upload-module/
+---
+
+A module that only runs locally on your development machine is useful for testing
+but limits what you can do. Deploying through the Viam module registry lets you:
+
+- **Install on any machine** in your organization (or publicly) through the
+ Viam app -- no SSH or manual file copying.
+- **Update over the air** -- release a new version and machines pick it up
+ automatically within minutes.
+- **Target multiple platforms** -- cloud build compiles for every architecture
+ you need so you don't have to cross-compile locally.
+
+For background on the module registry, versioning, and cloud builds, see the
+[overview](/build-modules/overview/#the-module-registry).
+
+## Steps
+
+### 1. Review meta.json
+
+The generator creates a `meta.json` file in your module directory. Open it and
+review each field:
+
+```json
+{
+ "module_id": "my-org:my-sensor-module",
+ "visibility": "private",
+ "url": "https://github.com/my-org/my-sensor-module",
+ "description": "A custom sensor module that reads temperature and humidity from an HTTP endpoint.",
+ "models": [
+ {
+ "api": "rdk:component:sensor",
+ "model": "my-org:my-sensor-module:my-sensor"
+ }
+ ],
+ "entrypoint": "run.sh",
+ "build": {
+ "setup": "./setup.sh",
+ "build": "./build.sh",
+ "path": "dist/archive.tar.gz",
+ "arch": ["linux/amd64", "linux/arm64"]
+ }
+}
+```
+
+| Field | Required | Purpose |
+| ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `$schema` | No | JSON Schema URL for editor validation. Set to `https://dl.viam.dev/module.schema.json`. |
+| `module_id` | Yes | Unique ID in the registry. Format: `namespace:name`. |
+| `visibility` | Yes | Who can see and install the module: `private`, `public`, or `public_unlisted`. |
+| `url` | No | Link to the source code repository. Required for cloud builds. |
+| `description` | Yes | Shown in the registry UI and search results. |
+| `models` | No | List of resource models the module provides. Each has `api`, `model`, and optionally `short_description` and `markdown_link`. Models can be auto-detected with `viam module update-models --binary`. |
+| `entrypoint` | Yes | The path to the command that starts the module inside the archive. |
+| `first_run` | No | Path to a setup script that runs once after first install (default timeout: 1 hour). |
+| `markdown_link` | No | Path to a README file (or `README.md#section` anchor) used as the registry description. |
+| `build.setup` | No | Script that installs build dependencies (runs once). |
+| `build.build` | No | Script that compiles and packages the module. |
+| `build.path` | No | Path to the packaged output archive (default: `module.tar.gz`). |
+| `build.arch` | No | Target platforms to build for (default: `["linux/amd64", "linux/arm64"]`). |
+| `build.darwin_deps` | No | Homebrew dependencies for macOS builds (for example, `["go", "pkg-config"]`). |
+
+Visibility options:
+
+- **`private`** -- only your organization can see and use the module.
+- **`public`** -- all Viam users can see and use it. Requires your organization
+ to have a public namespace.
+- **`public_unlisted`** -- any user can use the module if they know the ID, but
+ it does not appear in registry search results.
+
+Common `api` values:
+
+- `rdk:component:sensor` for sensors
+- `rdk:component:camera` for cameras
+- `rdk:component:motor` for motors
+- `rdk:component:generic` for generic components
+- `rdk:service:vision` for vision services
+
+### 2. Review the generated build scripts
+
+The generator creates build and setup scripts for your module. Review them
+and customize if needed:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+| File | Purpose |
+| ---------- | ---------------------------------------------------- |
+| `setup.sh` | Installs Python dependencies from `requirements.txt` |
+| `build.sh` | Packages the module into a `.tar.gz` archive |
+| `run.sh` | Entrypoint script that starts the module |
+
+If your module has additional build steps (for example, compiling native extensions),
+add them to `build.sh`.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+| File | Purpose |
+| ---------- | --------------------------------------------------------------------- |
+| `setup.sh` | Installs build dependencies (Go modules are typically self-contained) |
+| `build.sh` | Cross-compiles the binary and packages it into a `.tar.gz` archive |
+| `Makefile` | Local build targets |
+
+The generated `build.sh` uses `GOOS` and `GOARCH` environment variables to
+cross-compile for the target platform. Cloud build sets these automatically.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+Make sure all scripts are executable:
+
+```bash
+chmod +x setup.sh build.sh run.sh
+```
+
+### 3. Write a README (recommended)
+
+Document your module and its models so users know how to configure and use
+them. If you plan to make your module public, a good README is essential.
+
+{{< expand "Module README template" >}}
+
+```md
+# `my-sensor-module`
+
+This module implements the [Viam sensor API](https://docs.viam.com/reference/apis/components/sensor/) in a `my-org:my-sensor-module:my-sensor` model.
+With this model, you can gather temperature and humidity data from a custom HTTP endpoint.
+
+Navigate to the **CONFIGURE** tab of your machine's page.
+Click the **+** button, select **Configuration block**, then select the `sensor / my-sensor-module:my-sensor` model provided by the [`my-sensor-module` module](https://app.viam.com/module/my-org/my-sensor-module).
+Click **Add module**, enter a name for your sensor, and click **Create**.
+
+## Models
+
+This module provides the following model(s):
+
+- [`my-org:my-sensor-module:my-sensor`](#my-sensor) - A custom sensor that reads temperature and humidity from an HTTP endpoint
+```
+
+{{< /expand >}}
+
+{{< expand "Model README template" >}}
+
+````md
+# Model `my-org:my-sensor-module:my-sensor`
+
+A description of what this model does and what hardware or services it supports.
+
+## Configuration
+
+The following attribute template can be used to configure this model:
+
+```json
+{
+ "source_url": "",
+ "poll_interval":
+}
+```
+
+### Attributes
+
+| Name | Type | Required | Description |
+| --------------- | ------ | -------- | ------------------------------------------- |
+| `source_url` | string | Yes | The HTTP endpoint to read sensor data from |
+| `poll_interval` | float | No | Polling interval in seconds (default: 10.0) |
+
+### Example Configuration
+
+```json
+{
+ "source_url": "https://api.example.com/sensor/data",
+ "poll_interval": 5.0
+}
+```
+
+## DoCommand
+
+If your model implements DoCommand, document each supported command.
+
+### Example DoCommand
+
+```json
+{
+ "command": "calibrate",
+ "offset": 1.5
+}
+```
+````
+
+{{< /expand >}}
+
+You can point your module's registry page to a README by setting the
+`markdown_link` field in `meta.json` to a file path (for example, `README.md`) or a
+section anchor (for example, `README.md#my-sensor`).
+
+### 4. Deploy with cloud build (recommended)
+
+Cloud build is the recommended way to deploy modules. It uses GitHub Actions to
+compile your module for every target platform automatically, so you don't need
+to cross-compile locally.
+
+The generator creates the workflow file at
+`.github/workflows/deploy.yml`. To use it:
+
+**Push your code to GitHub:**
+
+```bash
+cd my-sensor-module
+git init
+git add .
+git commit -m "Initial module code"
+git remote add origin https://github.com/my-org/my-sensor-module.git
+git push -u origin main
+```
+
+**Add Viam credentials as GitHub secrets:**
+
+1. In the [Viam app](https://app.viam.com), go to your organization's settings.
+2. Create an API key with organization-level access (or use an existing one).
+3. In your GitHub repository, go to **Settings > Secrets and variables >
+ Actions**.
+4. Add two secrets:
+ - `VIAM_KEY_ID` -- your API key ID
+ - `VIAM_KEY_VALUE` -- your API key
+
+**Tag a release to trigger the build:**
+
+```bash
+git tag v0.1.0
+git push origin v0.1.0
+```
+
+The GitHub Action runs automatically. Monitor progress in the **Actions** tab
+of your GitHub repository. When it completes, your module is in the registry
+and ready to install on any machine.
+
+You can also trigger a cloud build from the CLI:
+
+```bash
+viam module build start
+```
+
+{{< alert title="Non-main default branches" color="tip" >}}
+Cloud build expects your repository's default branch to be `main`. If your
+repository uses a different default branch (for example, `master`), use the `--ref`
+flag:
+
+```bash
+viam module build start --ref master
+```
+
+{{< /alert >}}
+
+### 5. Deploy manually (alternative)
+
+If you cannot use cloud build, you can build and upload from the command line.
+
+{{< alert title="You must build for the target platform" color="caution" >}}
+When you upload manually, the binary in your archive must already be compiled
+for the target machine's OS and architecture. If you build on an x86 laptop
+and upload for `linux/arm64` without cross-compiling, the module will fail
+with an exec format error on ARM machines (like Raspberry Pi).
+
+Cloud build handles this automatically. If you deploy manually, you must
+cross-compile yourself or build on a machine with the target architecture.
+{{< /alert >}}
+
+**Build locally:**
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```bash
+cd my-sensor-module
+bash build.sh
+```
+
+The generated `build.sh` uses [PyInstaller](https://pypi.org/project/pyinstaller/)
+to compile your module into a standalone executable containing the Python
+interpreter and all dependencies.
+
+{{< alert title="PyInstaller limitations" color="note" >}}
+
+- PyInstaller does not support relative imports in entrypoints (imports
+ starting with `.`). If you get `"ImportError: attempted relative import with
+no known parent package"`, see the
+ [PyInstaller workaround](https://github.com/pyinstaller/pyinstaller/issues/2560).
+- PyInstaller does not support cross-compilation. Use
+ [cloud build](#4-deploy-with-cloud-build-recommended) to build for multiple
+ architectures automatically.
+ {{< /alert >}}
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```bash
+cd my-sensor-module
+# Cross-compile for the target platform
+GOOS=linux GOARCH=arm64 go build -o dist/module cmd/module/main.go
+tar -czf dist/archive.tar.gz -C dist module
+```
+
+Set `GOARCH` to match your target machine: `amd64` for x86_64, `arm64` for
+ARM (Raspberry Pi 4, Jetson, etc.).
+
+{{% /tab %}}
+{{< /tabs >}}
+
+**Upload to the registry:**
+
+```bash
+viam module upload \
+ --version=0.1.0 \
+ --platform=linux/arm64 \
+ dist/archive.tar.gz
+```
+
+To support multiple platforms, cross-compile and upload once per platform:
+
+```bash
+# Build and upload for amd64
+GOOS=linux GOARCH=amd64 go build -o dist/module cmd/module/main.go
+tar -czf dist/archive.tar.gz -C dist module
+viam module upload --version=0.1.0 --platform=linux/amd64 dist/archive.tar.gz
+
+# Build and upload for arm64
+GOOS=linux GOARCH=arm64 go build -o dist/module cmd/module/main.go
+tar -czf dist/archive.tar.gz -C dist module
+viam module upload --version=0.1.0 --platform=linux/arm64 dist/archive.tar.gz
+```
+
+### 6. Configure the module on a machine
+
+Once your module is in the registry, any machine in your organization can use it.
+
+1. In the [Viam app](https://app.viam.com), navigate to your machine's
+ **CONFIGURE** tab.
+2. Click **+** and select **Configuration block**.
+3. Search for your module by name or browse the registry.
+4. Add the module and create a component (or service) instance.
+5. Name the component and configure the attributes your module expects:
+
+```json
+{
+ "source_url": "https://api.example.com/sensor/data"
+}
+```
+
+7. Click **Save**.
+
+`viam-server` downloads the module from the registry, starts it, and makes the
+component available. Test it from the **CONTROL** tab.
+
+### 7. Manage versions
+
+**Release a new version:**
+
+```bash
+git add .
+git commit -m "Add humidity calibration offset"
+git tag v0.2.0
+git push origin main v0.2.0
+```
+
+If using cloud build, the workflow runs automatically. For manual upload:
+
+```bash
+viam module upload --version=0.2.0 --platform=linux/amd64 dist/archive.tar.gz
+```
+
+**Automatic updates:** By default, machines track the latest version. When you
+upload `v0.2.0`, all machines update automatically within a few minutes.
+
+**Pin to a specific version:**
+
+1. In the Viam app, go to the machine's **CONFIGURE** tab.
+2. Find the module in the configuration.
+3. Set the **Version** field to the specific version (for example, `0.1.0`).
+4. Click **Save**.
+
+**View module details:**
+
+You can view version history and details for your module in the
+[Viam registry](https://app.viam.com/registry).
+
+### 8. Keep meta.json in sync with your code
+
+As you add models to your module, you can auto-detect them from a built binary
+instead of editing `meta.json` by hand:
+
+```bash
+viam module update-models --binary ./bin/module
+```
+
+This inspects the binary, discovers registered models, and updates the `models`
+array in `meta.json`.
+
+Then push the updated metadata to the registry:
+
+```bash
+viam module update
+```
+
+### 9. Download a module
+
+To download a module from the registry (for testing or inspection):
+
+```bash
+viam module download --id my-org:my-sensor-module --version 0.1.0 --platform linux/amd64 --destination ./downloaded-module
+```
+
+### Platform constraints
+
+When uploading, you can attach platform constraint tags that restrict which
+machines can use a particular upload. For example, to require Debian:
+
+```bash
+viam module upload \
+ --version=0.1.0 \
+ --platform=linux/amd64 \
+ --tags=distro:debian \
+ dist/archive.tar.gz
+```
+
+The machine must report matching platform tags for the constrained upload to be
+selected. If no constraints are specified, the upload is available to all
+machines on that platform.
+
+### Upload limits
+
+The registry enforces these size limits:
+
+| Limit | Value |
+| ------------------------------ | ------ |
+| Compressed package (`.tar.gz`) | 50 GB |
+| Decompressed contents | 250 GB |
+| Single file within package | 25 GB |
+
+Before uploading, the CLI validates that:
+
+- The entrypoint executable exists in the archive
+- The entrypoint has execute permissions
+- No symlinks escape the archive boundaries
+
+Use `--force` to skip these checks (not recommended for production uploads).
+
+## Try It
+
+1. Review the generated `meta.json` and build scripts in your module directory.
+2. Push your code to GitHub and add the Viam API key secrets.
+3. Tag a release (`v0.1.0`) to trigger a cloud build.
+4. Navigate to a machine in the Viam app and add your module from the registry.
+5. Configure a component and set the required attributes.
+6. Open the **CONTROL** tab and verify the component works.
+7. Tag a new release (`v0.2.0`) and verify the machine picks it up
+ automatically within a few minutes.
+
+## Troubleshooting
+
+{{< expand "Upload fails with \"not authenticated\"" >}}
+
+- Log in to the Viam CLI: `viam login`.
+- If using an API key, verify it has organization-level access.
+- Check that `VIAM_KEY_ID` and `VIAM_KEY_VALUE` are set correctly in your GitHub
+ secrets (for cloud build).
+
+{{< /expand >}}
+
+{{< expand "Upload fails with \"invalid meta.json\"" >}}
+
+- Verify `meta.json` is valid JSON. Run `python -m json.tool meta.json` or
+ `jq . meta.json` to check.
+- Confirm the `module_id` matches the format `namespace:module-name`.
+- Ensure all model entries have both `api` and `model` fields.
+
+{{< /expand >}}
+
+{{< expand "Module not appearing in the registry" >}}
+
+- Check the module's visibility. If it is `private`, it only appears for users
+ in your organization.
+- Verify the upload completed successfully.
+- The module may take a minute to propagate. Refresh the page and try again.
+
+{{< /expand >}}
+
+{{< expand "Machine cannot find the module" >}}
+
+- Verify the module version supports the machine's platform. If your machine
+ runs `linux/arm64` but you only uploaded for `linux/amd64`, the machine
+ cannot use it.
+- Check the module version. If the machine is pinned to a nonexistent version,
+ it will fail.
+- Confirm the machine is online and connected to the cloud.
+
+{{< /expand >}}
+
+{{< expand "Cloud build fails in GitHub Actions" >}}
+
+- Check the Actions tab in your GitHub repository for the build log.
+- If your repository's default branch is not `main` (for example, it uses `master`),
+ use `viam module build start --ref master`. The cloud build system expects
+ `main` by default.
+- Verify your `setup.sh` and `build.sh` scripts work locally.
+- Confirm the `build.path` in `meta.json` matches the actual output location.
+- Ensure the GitHub secrets are set and not expired.
+
+{{< /expand >}}
+
+{{< expand "Exec format error on target machine" >}}
+
+This means the binary was compiled for the wrong architecture. For example,
+you built on an x86 laptop but the target machine is ARM (Raspberry Pi).
+
+- Use [cloud build](#4-deploy-with-cloud-build-recommended) to compile for all
+ target platforms automatically.
+- If deploying manually, cross-compile with the correct `GOOS` and `GOARCH`
+ before uploading. See [Deploy manually](#5-deploy-manually-alternative).
+- Verify the platform flag in your `upload` command matches the binary's
+ architecture (for example, `--platform=linux/arm64` for Raspberry Pi 4).
+
+{{< /expand >}}
+
+{{< expand "Module works locally but fails after deployment" >}}
+
+- Check for hard-coded paths, missing environment variables, or dependencies
+ installed on your machine but not in the build environment.
+- For Python, verify all dependencies are in `requirements.txt`.
+- For Go, verify the binary is compiled for the correct target architecture.
+- Check the module logs in the **LOGS** tab.
+
+{{< /expand >}}
diff --git a/docs/build-modules/manage-modules.md b/docs/build-modules/manage-modules.md
new file mode 100644
index 0000000000..d13e9d127a
--- /dev/null
+++ b/docs/build-modules/manage-modules.md
@@ -0,0 +1,450 @@
+---
+title: "Update and manage modules you created"
+linkTitle: "Update and manage modules"
+type: "docs"
+weight: 32
+images: ["/registry/create-module.svg"]
+icon: true
+tags: ["modular resources", "components", "services", "registry"]
+description: "Update or delete your existing modules, or change their privacy settings."
+aliases:
+ - /use-cases/deploy-code/
+ - /use-cases/manage-modules/
+ - /how-tos/manage-modules/
+languages: []
+viamresources: []
+platformarea: ["registry"]
+level: "Beginner"
+date: "2024-06-30"
+# updated: "" # When the tutorial was last entirely checked
+cost: "0"
+---
+
+After you [create and upload a module](/build-modules/write-a-driver-module/), you can update, delete, or change its visibility settings.
+
+For information on pinning module deployments to versions, see [Module versioning](/build-modules/deploy-a-module/#7-manage-versions).
+
+## Update a module
+
+Once your module is in the [registry](https://app.viam.com/registry), there are two ways to update it:
+
+- [Update automatically](#update-automatically-from-a-github-repo-with-cloud-build) using GitHub Actions: Recommended for ongoing projects with continuous integration (CI) workflows, or if you want to build for multiple platforms.
+
+ - If you enabled cloud build when you generated your module, the GitHub Actions are already set up for you.
+
+- [Update manually](#update-manually) using the [Viam CLI](/cli/): Fine for small projects with one contributor.
+
+### Update automatically from a GitHub repo with cloud build
+
+Use [GitHub Actions](https://docs.github.com/actions) to automatically build and deploy your new module version when you create a new release in GitHub:
+
+1. Edit your module code and update the [`meta.json`](/build-modules/module-reference/) file if needed.
+ For example, if you've changed the module's functionality, update the description in the `meta.json` file.
+
+ {{% alert title="Important" color="note" %}}
+ Make sure the `url` field contains the URL of the GitHub repo that contains your module code.
+ This field is required for cloud build to work.
+ {{% /alert %}}
+
+1. Push your changes to your module GitHub repository.
+
+ {{% alert title="Tip" color="tip" %}}
+
+ If you used `viam module generate` to create your module and enabled cloud build, and you followed all the [steps to deploy with cloud build](/build-modules/deploy-a-module/#4-deploy-with-cloud-build-recommended) including adding API keys for the build action, all you need to do to trigger a new build is publish a release in GitHub as you did when you first published the module.
+
+ {{% /alert %}}
+
+1. If you did not use the Viam CLI generator and enable cloud build, you can set up one of the following GitHub Actions up manually:
+
+ {{< tabs >}}
+ {{% tab name="build-action (Recommended)" %}}
+
+ The `build-action` GitHub action provides a cross-platform build setup for multiple platforms: x86, ARM Linux, and macOS.
+
+ In your repository, create a workflow file called build.yml in the .github/workflows directory:
+
+ ```yaml
+ on:
+ release:
+ types: [published]
+
+ jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: viamrobotics/build-action@v1
+ with:
+ version: ${{ github.ref_name }}
+ ref: ${{ github.sha }}
+ key-id: ${{ secrets.viam_key_id }}
+ key-value: ${{ secrets.viam_key_value }}
+ token: ${{ github.token }} # only required for private git repos
+ ```
+
+The `build-action` GitHub action relies on a build command that you need to specify in the meta.json file.
+At the end of your meta.json , add the build configuration:
+
+
+
+```json {class="line-numbers linkable-line-numbers" data-line="5-9"}
+{
+ "module_id": "example-module",
+ ...
+ "build": {
+ "setup": "./setup.sh", // optional - command for one-time setup
+ "build": "./build.sh", // command that will build your module's tarball
+ "path" : "dist/archive.tar.gz", // optional - path to your built module tarball
+ "arch" : ["linux/amd64", "linux/arm64", "darwin/arm64"], // architecture(s) to build for
+ "darwin_deps" : ["go", "x264", "nlopt-static"] // optional - Homebrew dependencies for Darwin builds. Explicitly pass `[]` for empty.
+ }
+}
+```
+
+{{% expand "Cloud build configuration attributes" %}}
+
+
+| Attribute | Inclusion | Description |
+| --------- | --------- | ----------- |
+| `"setup"` | Optional | Command to run for setting up the build environment. |
+| `"build"` | **Required** | Command to run to build the module tarball. |
+| `"path"` | Optional | Path to the build module tarball. |
+| `"arch"` | **Required** | Array of architectures to build for. For more information see [Supported platforms for automatic updates](#supported-platforms-for-automatic-updates). |
+| `"darwin_deps"` | **Required** | Array of homebrew dependencies for Darwin builds. Explicitly pass `[]` for empty. Default: `["go", "pkg-config", "nlopt-static", "x264", "jpeg-turbo", "ffmpeg"]` |
+
+{{% /expand %}}
+
+{{< expand "Python module script examples" >}}
+
+The following code snippet demonstrates an example `setup.sh` for a Python module:
+
+```bash {class="line-numbers linkable-line-numbers"}
+#!/bin/sh
+cd `dirname $0`
+
+# Create a virtual environment to run our code
+VENV_NAME="venv"
+PYTHON="$VENV_NAME/bin/python"
+ENV_ERROR="This module requires Python >=3.8, pip, and virtualenv to be installed."
+
+if ! python3 -m venv $VENV_NAME >/dev/null 2>&1; then
+ echo "Failed to create virtualenv."
+ if command -v apt-get >/dev/null; then
+ echo "Detected Debian/Ubuntu, attempting to install python3-venv automatically."
+ SUDO="sudo"
+ if ! command -v $SUDO >/dev/null; then
+ SUDO=""
+ fi
+ if ! apt info python3-venv >/dev/null 2>&1; then
+ echo "Package info not found, trying apt update"
+ $SUDO apt -qq update >/dev/null
+ fi
+ $SUDO apt install -qqy python3-venv >/dev/null 2>&1
+ if ! python3 -m venv $VENV_NAME >/dev/null 2>&1; then
+ echo $ENV_ERROR >&2
+ exit 1
+ fi
+ else
+ echo $ENV_ERROR >&2
+ exit 1
+ fi
+fi
+
+# remove -U if viam-sdk should not be upgraded whenever possible
+# -qq suppresses extraneous output from pip
+echo "Virtualenv found/created. Installing/upgrading Python packages..."
+if ! [ -f .installed ]; then
+ if ! $PYTHON -m pip install -r requirements.txt -Uqq; then
+ exit 1
+ else
+ touch .installed
+ fi
+fi
+```
+
+The following code snippet demonstrates an example `build.sh` for a Python module:
+
+```bash {class="line-numbers linkable-line-numbers"}
+#!/bin/sh
+cd `dirname $0`
+
+# Create a virtual environment to run our code
+VENV_NAME="venv"
+PYTHON="$VENV_NAME/bin/python"
+
+if ! $PYTHON -m pip install pyinstaller -Uqq; then
+ exit 1
+fi
+
+$PYTHON -m PyInstaller --onefile --hidden-import="googleapiclient" src/main.py
+tar -czvf dist/archive.tar.gz ./dist/main
+```
+
+{{< /expand >}}
+
+You can test this build configuration by running the Viam CLI's [`build local` command](/cli/#using-the-build-subcommand) on your development machine:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build local
+```
+
+The command will run your build instructions locally without running a cloud build job.
+
+For more details, see the [`build-action` GitHub Action documentation](https://github.com/viamrobotics/build-action), or take a look through one of the following example repositories that show how to package and deploy modules using the Viam SDKs:
+
+- [Golang CI yaml](https://github.com/viam-labs/wifi-sensor/blob/main/.github/workflows/build.yml)
+- [Golang Example CI meta.json](https://github.com/viam-labs/wifi-sensor/blob/main/meta.json)
+
+
+ {{% /tab %}}
+ {{% tab name="upload-module" %}}
+
+ If you already have your own CI with access to arm runners or only intend to build on `x86` or `mac`, you can use the `upload-module` GitHub action instead which allows you to define the exact build steps.
+
+ Add this to your GitHub workflow:
+
+ ```yaml {class="line-numbers linkable-line-numbers"}
+ on:
+ push:
+ release:
+ types: [released]
+
+ jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - name: build
+ run: echo "your build command goes here" && false # <-- replace this with the command that builds your module's tar.gz
+ - uses: viamrobotics/upload-module@v1
+ # if: github.event_name == 'release' # <-- once the action is working, uncomment this so you only upload on release
+ with:
+ module-path: module.tar.gz
+ platform: linux/amd64 # <-- replace with your target architecture, or your module will not deploy
+ version: ${{ github.event_name == 'release' && github.ref_name || format('0.0.0-{0}.{1}', github.ref_name, github.run_number) }} # <-- see 'Versioning' section below for explanation
+ key-id: ${{ secrets.viam_key_id }}
+ key-value: ${{ secrets.viam_key_value }}
+ ```
+
+Set `run` to the command you use to build and package your module, such as invoking a makefile or running a shell script.
+When you are ready to test the action, uncomment `if: github.event_name == 'release'` to enable the action to trigger a run when you [issue a release](https://docs.github.com/en/repositories/releasing-projects-on-github).
+
+For guidance on configuring the other parameters, see the documentation for each:
+
+- [`org-id`](/cli/#using-the---org-id-and---public-namespace-arguments): Not required if your module is public.
+- [`platform`](/cli/#using-the---platform-argument): You can only upload one platform at a time.
+- [`version`](https://github.com/viamrobotics/upload-module/blob/main/README.md#versioning): See [Using the --version argument](/cli/#using-the---version-argument) for more details on the types of versioning supported.
+
+For more details, see the [`upload-module` GitHub Action documentation](https://github.com/viamrobotics/upload-module), or take a look through one of the following example repositories that show how to package and deploy modules using the Viam SDKs:
+
+- [Python with virtualenv](https://github.com/viam-labs/python-example-module)
+- [Python with docker](https://github.com/viamrobotics/python-container-module)
+- [Golang](https://github.com/viam-labs/wifi-sensor)
+- [C++](https://github.com/viamrobotics/module-example-cpp)
+
+ {{% /tab %}}
+ {{< /tabs >}}
+
+3. [Create an organization API key](/cli/#create-an-organization-api-key) with owner role:
+
+ ```sh {class="command-line" data-prompt="$"}
+ viam organizations api-key create --org-id --name
+ ```
+
+1. Add the key ID and value as GitHub repository secrets named `viam_key_id` and `viam_key_value`.
+
+1. Create a [release](https://docs.github.com/en/repositories/releasing-projects-on-github) in GitHub to trigger the build.
+ The build can be quick or take over 15 minutes to complete, depending on factors including the size of the module.
+
+ Once the build is complete, the module will automatically update in the [registry](https://app.viam.com/registry), and the machines set to use the latest [version](/build-modules/deploy-a-module/#7-manage-versions) of the module will automatically update to the new version.
+
+#### Supported platforms for automatic updates
+
+When using cloud build, you can specify which platforms you want to build your module for in the `arch` field of your `meta.json` file.
+The following table lists all available platforms:
+
+
+| Platform | Supported in cloud build | Container used by cloud build | Notes |
+| -------- | ------------------------ | ----------------------------- | ----- |
+| `linux/amd64` | ✅ | Ubuntu | Standard x86_64 Linux platform. |
+| `linux/arm64` | ✅ | Ubuntu | For ARM64 Linux devices like Raspberry Pi 4. |
+| `darwin/arm64` | ✅ | macOS | For Apple Silicon Macs (M1/M2/M3). |
+| `linux/arm32v6` | ❌ | N/A | For older ARM devices; must be built manually. |
+| `linux/arm32v7` | ❌ | N/A | For 32-bit ARM devices; must be built manually. |
+| `windows/amd64` | ⚠️ | N/A | Cloud builds work for Go modules but have issues linking to C libraries. Windows support is still in development. |
+| `darwin/amd64` | ❌ | N/A | Intel Macs; must be built manually. You can choose to support it, though many modules do not since Apple is phasing out this platform. |
+
+{{% alert title="Note" color="note" %}}
+While the registry supports additional platforms like `windows/amd64`, `linux/arm32v6`, and `linux/arm32v7`, these are not currently supported by cloud build and must be [built and uploaded manually](#update-manually).
+{{% /alert %}}
+
+### Update manually
+
+Use the [Viam CLI](/cli/) to manually update your module:
+
+1. Edit your module code and update the [`meta.json`](/build-modules/module-reference/) file if needed.
+ For example, if you've changed the module's functionality, update the description in the `meta.json` file.
+
+2. Package your module files as an archive:
+
+ ```sh {class="command-line" data-prompt="$"}
+ tar -czf module.tar.gz run.sh requirements.txt src meta.json
+ ```
+
+ For Go modules, build the binary first, then package it:
+
+ ```sh {class="command-line" data-prompt="$"}
+ tar -czf module.tar.gz my-module meta.json
+ ```
+
+ Supply the path to the resulting archive file in the next step.
+
+3. Upload to the Viam Registry:
+
+ ```sh {class="command-line" data-prompt="$"}
+ viam module upload --version --platform
+ ```
+
+ For example, `viam module upload --version 1.0.1 --platform darwin/arm64 my-module.tar.gz`.
+
+When you `upload` a module, the command performs basic [validation](/cli/#upload-validation) of your module to check for common errors.
+
+For more information, see the [`viam module` command](/cli/#module).
+
+## Change module visibility
+
+You can change the visibility of a module from public to private if:
+
+- you are an [owner](/organization/rbac/) of the {{< glossary_tooltip term_id="organization" text="organization" >}} that owns the module, AND
+- no machines outside of the organization that owns the module have the module configured (no other orgs are using it).
+
+To change the visibility:
+
+1. Navigate to your module's page in the [registry](https://app.viam.com/registry).
+2. Hover to the right of the visibility indicator near the right side of the page until an **Edit** button appears, and click it to make changes.
+
+ {{}}
+
+ The options are:
+
+ - **Private**: Only users inside your organization can view, use, and edit the module.
+ - **Public**: Any user inside or outside of your organization can view, use, and edit the module.
+ - **Unlisted**: Any user inside or outside of your organization, with a direct link, can view and use the module.
+ Only organization members can edit the module.
+ Not listed in the registry for users outside of your organization.
+
+You can also edit the visibility by editing the [meta.json](/build-modules/module-reference/) file and then running the following [CLI](/cli/#module) command:
+
+```sh {id="terminal-prompt" class="command-line" data-prompt="$"}
+viam module update
+```
+
+{{% hiddencontent %}}
+If you don't see a private module of yours in the registry, make sure that you have the correct organization selected in the upper right corner of the page.
+{{% /hiddencontent %}}
+
+## Delete a module
+
+You can delete a module if:
+
+- you are an [owner](/organization/rbac/) in the {{< glossary_tooltip term_id="organization" text="organization" >}} that owns the module, AND
+- no machines have the module configured.
+
+To delete a module:
+
+1. Navigate to its page in the [registry](https://app.viam.com/registry).
+2. Click the **...** menu in the upper-right corner of the page, and click **Delete**.
+
+ {{}}
+
+{{% alert title="Note" color="note" %}}
+
+If you need to delete a module and the delete option is unavailable to you, please [contact our support team](mailto:contact@viam.com) for assistance.
+
+{{% /alert %}}
+
+{{% hiddencontent %}}
+Other registry items such as training scripts and ML models can be deleted in the same way as modules.
+{{% /hiddencontent %}}
+
+### Delete just one version of a module
+
+Deleting a version of a module requires the same org owner permissions as deleting the entire module, and similarly, you cannot delete a version if any machines are using it.
+To delete just one version of a module:
+
+1. Navigate to its page in the [registry](https://app.viam.com/registry).
+
+1. Click **Show previous versions** under the **Latest version** heading.
+
+1. Hover on the architecture pill next to the version you'd like to delete and click the trash icon.
+
+You cannot upload a new file with the same version number as the deleted one.
+To upload another version, you must increment the version number to a later version number.
+
+## Transfer ownership of a module
+
+To transfer ownership of a module from one organization to another:
+
+1. You must be an [owner](/organization/rbac/) in both the current and new organizations.
+
+1. Navigate to the module's page in the [registry](https://app.viam.com/registry).
+
+1. Make sure the visibility of the module is set to **Public**.
+
+1. Click the **...** menu in the upper-right corner of the page, and click **Transfer ownership**.
+
+1. Select the new organization from the dropdown menu, then click **Transfer module**.
+
+1. (Recommended) Transfer the GitHub repository containing the module code to the new owner.
+ Be sure to remove the existing secrets from the repository's settings before transferring.
+ If the repository is using Viam's cloud build, the secrets contain an organization API key that will be exposed to the new owner after the repository transfer.
+
+1. Update the `meta.json` file to reflect the new organization:
+
+ - Change the first part of the `module_id` field to the new organization's [namespace](/build-modules/module-reference/#organization-namespace).
+ - For each model, change the first part of the `model` field to the new organization's namespace.
+ - Update the `url` field to point to the new code repository if it has moved.
+
+1. Update the module code:
+
+ - Throughout your module implementation code, change the model names in your component or service classes to match the changes you made to the `meta.json` file.
+
+1. Publish a new version of the module to the registry by following either set of update steps on this page.
+ This ensures that the model names in the module code match the registered model names in the registry.
+
+1. (Recommended) Update the `model` field in the configuration of any machines that use the module to use the new organization's namespace.
+ Viam maintains backwards compatibility with the old namespace, but you should update the configuration to use the new namespace to avoid confusion.
+
+## Rename a module
+
+You can rename a module that your organization owns through the Viam web interface.
+To rename a module:
+
+1. Navigate to your module page at `app.viam.com/module//`.
+1. Click the **...** menu in the top right corner of the module page.
+1. Select **Rename** from the dropdown menu.
+1. Enter the new module name in the modal that appears.
+1. Click **Rename** to confirm the change.
+
+When you rename a module, Viam reserves the old module name for backwards compatibility and you cannot reuse it.
+
+Existing machine configurations containing the old module name will continue to work.
+
+{{% hiddencontent %}}
+
+## Rename a model
+
+If you need to change the name of a model that a module implements, do the following:
+
+1. Update the `model` field in the `meta.json` file to the new model name.
+
+1. Update the model name in the module code to match the new model name.
+
+1. Publish a new version of the module to the registry by following either set of update steps on this page.
+
+1. (Recommended) Update the configuration of any machines that use the module to use the new model name.
+ Viam maintains backwards compatibility with the old model name, but updating the configuration is recommended to avoid confusion.
+
+{{% /hiddencontent %}}
diff --git a/docs/build-modules/module-anatomy.md b/docs/build-modules/module-anatomy.md
new file mode 100644
index 0000000000..ca8fe793e6
--- /dev/null
+++ b/docs/build-modules/module-anatomy.md
@@ -0,0 +1,483 @@
+---
+linkTitle: "Anatomy of a module"
+title: "Anatomy of a module"
+weight: 5
+layout: "docs"
+type: "docs"
+description: "Understand the directory structure and key files that make up a Viam module."
+---
+
+When you run `viam module generate`, the CLI creates a complete project with
+everything you need to build, test, and deploy a module. This page explains
+the purpose of each file and when you need to edit it.
+
+The examples on this page use a logic module called `temp-monitor` that
+monitors a temperature sensor and logs a warning when readings exceed a
+threshold. It depends on one sensor and uses `DoCommand` to report its
+current state.
+
+## Directory structure
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```text
+temp-monitor/
+├── .github/
+│ └── workflows/
+│ └── deploy.yml # CI workflow for cloud builds
+├── src/
+│ ├── main.py # Entry point
+│ └── models/
+│ └── temp_monitor.py # Resource implementation
+├── build.sh # Packages the module for upload
+├── meta.json # Module metadata for the registry
+├── requirements.txt # Python dependencies
+├── run.sh # Entrypoint script for viam-server
+└── setup.sh # Installs dependencies into a virtualenv
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```text
+temp-monitor/
+├── .github/
+│ └── workflows/
+│ └── deploy.yml # CI workflow for cloud builds
+├── cmd/
+│ └── module/
+│ └── main.go # Entry point
+├── temp_monitor.go # Resource implementation
+├── go.mod # Go module definition
+├── go.sum # Dependency checksums
+├── Makefile # Build targets
+├── meta.json # Module metadata for the registry
+├── build.sh # Packages the module for upload
+└── setup.sh # Installs build dependencies
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+## Resource implementation
+
+This is the main file you work in. The generator names it after the model you are implementing. A model implements a Viam API.
+In this example, that is `src/models/temp_monitor.py` in Python and
+`temp_monitor.go` in Go. The sections below walk through each section of this file.
+
+### Model definition
+
+The model definition identifies your resource in the registry as a triplet
+of namespace, module name, and model name. Some examples from built-in Viam
+modules: `viam:camera:webcam`, `viam:motor:gpio`, `viam:sensor:ultrasonic`.
+In our example, the triplet is `my-org:temp-monitor:temp-monitor`.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+class TempMonitor(Generic, EasyResource):
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "temp-monitor"), "temp-monitor"
+ )
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+var Model = resource.NewModel("my-org", "temp-monitor", "temp-monitor")
+
+func init() {
+ resource.RegisterService(generic.API, Model, resource.Registration[
+ resource.Resource, *Config,
+ ]{
+ Constructor: newTempMonitor,
+ })
+}
+```
+
+In Go, the `init()` function registers the model with `viam-server` when
+the package is imported. The registration binds the model to the generic
+service API and points to the constructor function.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Config and attributes
+
+Config attributes are the fields a user sets when they add the service this model implements to a
+machine. Each field maps to a key in the `attributes` block of the JSON
+config:
+
+```json
+"attributes": {
+ "sensor_name": "temp-1",
+ "threshold": 40.0
+}
+```
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ sensor_name: str
+ threshold: float
+ sensor: Sensor
+ exceeded: bool
+```
+
+In Python, declare config attributes, resolved dependencies, and runtime
+state as instance variables on the class. This pattern is the same for any
+module you write. Only the specific variables change.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+type Config struct {
+ SensorName string `json:"sensor_name"`
+ Threshold float64 `json:"threshold"`
+}
+
+type TempMonitor struct {
+ resource.Named
+ logger logging.Logger
+ cfg *Config
+ sensor sensor.Sensor
+ mu sync.Mutex
+ exceeded bool
+ cancelFn func()
+}
+```
+
+In Go, the `Config` struct defines the attributes. The `json` tags map each
+field to its key in the JSON config. The resource struct holds the parsed
+config, resolved dependencies, and runtime state. This pattern is the same
+for any module you write. Only the specific fields change.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Validation
+
+The validation method checks that attributes are valid and declares
+dependencies on other resources. `viam-server` calls this before creating or
+reconfiguring the resource. It returns two lists: required dependencies and
+optional dependencies.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ @classmethod
+ def validate_config(
+ cls, config: ComponentConfig
+ ) -> Tuple[Sequence[str], Sequence[str]]:
+ fields = config.attributes.fields
+ if "sensor_name" not in fields:
+ raise Exception("sensor_name is required")
+ return [fields["sensor_name"].string_value], []
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ if cfg.SensorName == "" {
+ return nil, nil, fmt.Errorf("sensor_name is required")
+ }
+ return []string{cfg.SensorName}, nil, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Constructor
+
+`viam-server` calls the constructor when it creates your resource. The
+constructor receives the config (containing the attributes the user set), a dependencies
+map (containing running instances of the resources you declared in your
+validation method), and in Go, a context and a logger.
+
+If your resource uses `AlwaysRebuild` (the generated default in Go),
+`viam-server` destroys and re-creates the resource on every config change,
+calling the constructor again. If you implement a `Reconfigure` method
+instead, `viam-server` calls that method in place without re-creating the
+resource.
+
+The constructor's job is to:
+
+1. **Parse the config** into typed fields you can use (for example, extract
+ `sensor_name` as a string and `threshold` as a float).
+2. **Resolve dependencies** by looking up each one by name from the
+ dependencies map. Each entry is a ready-to-use resource instance that
+ `viam-server` has already started.
+3. **Store everything on the struct or instance** so your API methods and
+ background tasks can use them.
+4. **Start background work** if your module runs continuously (for example,
+ a goroutine or async task that polls a sensor on an interval).
+
+To resolve a dependency, you look it up by name from the dependencies map.
+In Go, every resource type in the SDK provides a `FromDependencies` helper
+that does this and returns a typed interface (for example,
+`sensor.FromDependencies` returns a `sensor.Sensor`). In Python, you build
+the key with `Sensor.get_resource_name(name)` and index into the
+dependencies map directly.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ @classmethod
+ async def new(cls, config, dependencies) -> Self:
+ monitor = cls(config.name)
+ monitor.exceeded = False
+ monitor.reconfigure(config, dependencies)
+ return monitor
+
+ def reconfigure(self, config, dependencies) -> None:
+ fields = config.attributes.fields
+ self.sensor_name = fields["sensor_name"].string_value
+ self.threshold = (
+ fields["threshold"].number_value
+ if "threshold" in fields
+ else 100.0
+ )
+
+ self.sensor = dependencies[
+ Sensor.get_resource_name(self.sensor_name)
+ ]
+
+ ...
+```
+
+In Python, the common pattern is for `new` to call `reconfigure` so that
+config-reading and dependency resolution logic lives in one place.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func newTempMonitor(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+ logger logging.Logger,
+) (resource.Resource, error) {
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return nil, err
+ }
+ if cfg.Threshold == 0 {
+ cfg.Threshold = 100.0
+ }
+
+ s, err := sensor.FromDependencies(deps, cfg.SensorName)
+ if err != nil {
+ return nil, err
+ }
+
+ monitorCtx, cancelFn := context.WithCancel(context.Background())
+ tm := &TempMonitor{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ cfg: cfg,
+ sensor: s,
+ cancelFn: cancelFn,
+ }
+ go tm.monitor(monitorCtx)
+ return tm, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### API methods
+
+API methods are how external code interacts with your resource. For a logic
+module using the generic service API, the method is `DoCommand`. It accepts
+and returns arbitrary key-value maps, so you define your own command
+vocabulary.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ async def do_command(self, command, **kwargs):
+ if command.get("command") == "status":
+ return {
+ "exceeded": self.exceeded,
+ "threshold": self.threshold,
+ }
+ return {"error": f"unknown command: {command.get('command')}"}
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (tm *TempMonitor) DoCommand(
+ ctx context.Context, cmd map[string]interface{},
+) (map[string]interface{}, error) {
+ if cmd["command"] == "status" {
+ tm.mu.Lock()
+ defer tm.mu.Unlock()
+ return map[string]interface{}{
+ "exceeded": tm.exceeded,
+ "threshold": tm.cfg.Threshold,
+ }, nil
+ }
+ return nil, fmt.Errorf("unknown command: %v", cmd["command"])
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Close
+
+`viam-server` calls `Close` when it shuts down or removes the resource. Stop
+background tasks and release any resources here.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ async def close(self):
+ self._stop_event.set()
+ if self._monitor_task is not None:
+ await self._monitor_task
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (tm *TempMonitor) Close(ctx context.Context) error {
+ tm.cancelFn()
+ return nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+For complete working examples, see
+[Write a logic module](/build-modules/write-a-logic-module/) and
+[Write a driver module](/build-modules/write-a-driver-module/).
+
+## meta.json
+
+Module metadata used by the Viam registry. The generator creates this file
+and populates it from your answers to the generator prompts.
+
+```json
+{
+ "module_id": "my-org:temp-monitor",
+ "visibility": "private",
+ "url": "https://github.com/my-org/temp-monitor",
+ "description": "Logs a warning when a temperature sensor exceeds a threshold.",
+ "models": [
+ {
+ "api": "rdk:service:generic",
+ "model": "my-org:temp-monitor:temp-monitor"
+ }
+ ],
+ "entrypoint": "run.sh",
+ "build": {
+ "setup": "./setup.sh",
+ "build": "./build.sh",
+ "path": "dist/archive.tar.gz",
+ "arch": ["linux/amd64", "linux/arm64"]
+ }
+}
+```
+
+| Field | Purpose |
+| ------------- | ------------------------------------------------------------------- |
+| `module_id` | Unique ID in the registry. Format: `namespace:name`. |
+| `visibility` | Who can install the module: `private`, `public`, `public_unlisted`. |
+| `url` | Link to the source repository. Required for cloud builds. |
+| `description` | Shown in registry search results. |
+| `models` | Resource models the module provides, each with `api` and `model`. |
+| `entrypoint` | Command that starts the module inside the archive. |
+| `build.setup` | Script that installs build dependencies (runs once). |
+| `build.build` | Script that compiles and packages the module. |
+| `build.path` | Path to the packaged output archive. |
+| `build.arch` | Target platforms to build for. |
+
+For the full schema, see
+[Module developer reference](/build-modules/module-reference/#metajson-schema).
+
+## Files you rarely edit
+
+### Entry point
+
+The entry point starts the module server and registers your models with
+`viam-server`. You only edit this file when you add a second model to the
+module.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+**`src/main.py`**
+
+```python
+import asyncio
+from viam.module.module import Module
+from models.temp_monitor import TempMonitor # noqa: F401
+
+if __name__ == "__main__":
+ asyncio.run(Module.run_from_registry())
+```
+
+`run_from_registry()` discovers all imported resource classes and registers
+them. To add another model, import its class here.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+**`cmd/module/main.go`**
+
+```go
+package main
+
+import (
+ tempmonitor "my-org/temp-monitor"
+ "go.viam.com/rdk/module"
+ "go.viam.com/rdk/resource"
+ "go.viam.com/rdk/services/generic"
+)
+
+func main() {
+ module.ModularMain(
+ resource.APIModel{generic.API, tempmonitor.Model},
+ )
+}
+```
+
+`ModularMain` handles socket parsing, signal handling, and graceful shutdown.
+The import of the resource package triggers its `init()` function, which
+registers the model. To add another model, add another `resource.APIModel`
+entry.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Build and deploy scripts
+
+These scripts handle packaging and deployment. The generator creates working
+defaults. You only need to edit them if your module has unusual build
+requirements.
+
+| File | Purpose |
+| ------------ | ---------------------------------------------------------------------------------------------- |
+| `build.sh` | Compiles (Go) or packages (Python) the module into a `.tar.gz` archive. |
+| `setup.sh` | Installs build dependencies. For Python, creates a virtualenv and installs `requirements.txt`. |
+| `run.sh` | (Python only) Entrypoint script that activates the virtualenv and runs `main.py`. |
+| `deploy.yml` | GitHub Actions workflow that triggers cloud builds on tagged releases. |
diff --git a/docs/build-modules/module-reference.md b/docs/build-modules/module-reference.md
new file mode 100644
index 0000000000..be43563eda
--- /dev/null
+++ b/docs/build-modules/module-reference.md
@@ -0,0 +1,587 @@
+---
+linkTitle: "Module reference"
+title: "Module developer reference"
+weight: 40
+layout: "docs"
+type: "docs"
+description: "Reference for module developers: lifecycle, interfaces, meta.json schema, CLI commands, environment variables, and registry rules."
+date: "2025-03-05"
+aliases:
+ - /development/module-reference/
+---
+
+This page is a reference for module developers. For step-by-step
+instructions, see [Write a Module](/build-modules/write-a-driver-module/) and
+[Deploy a Module](/build-modules/deploy-a-module/).
+
+## Module lifecycle
+
+Every module, local or registry, runs as a separate child process alongside
+`viam-server`, communicating over [gRPC](#module-protocol).
+
+1. `viam-server` starts and checks for configuration updates (if online).
+2. If the module has a [`first_run`](#first-run-scripts) script that hasn't
+ succeeded yet, `viam-server` runs it before starting the module.
+3. `viam-server` starts each configured module as a child process, passing it a
+ socket address.
+4. The module registers its models and APIs with `viam-server` through the
+ [`Ready` RPC](#module-protocol).
+5. For each configured resource, `viam-server` calls `ValidateConfig` to check
+ attributes and discover dependencies.
+6. `viam-server` starts required dependencies first. If a required dependency
+ fails, the resource that depends on it does not start.
+7. `viam-server` calls `AddResource` to create each resource. The module's
+ constructor runs, typically calling `Reconfigure` to read config.
+8. The resource is available for use.
+9. When the user changes configuration, `viam-server` calls
+ `ReconfigureResource`. Your `Reconfigure` method should complete
+ within the per-resource configuration timeout (default: 2 minutes,
+ configurable with `VIAM_RESOURCE_CONFIGURATION_TIMEOUT`).
+10. On shutdown, `viam-server` sends `RemoveResource` for each resource, then
+ terminates the module process.
+
+### First-run scripts
+
+If your module needs one-time setup (installing system packages, downloading
+models, etc.), set the `first_run` field in [`meta.json`](#metajson-schema) to
+the path of a setup script inside your archive.
+
+- The script runs **before** the module entrypoint, with the same
+ [environment variables](#environment-variables) available to the module.
+- If the script exits with a non-zero status, reconfiguration is aborted.
+ Currently running modules continue with their previous configuration.
+- On success, a `.first_run_succeeded` marker file is created next to the
+ module binary. The script will not re-run unless this marker is deleted
+ or a new module version is installed.
+- The default timeout is 1 hour, configurable through `first_run_timeout` in the
+ module config.
+
+### Crash recovery
+
+If a module process crashes, `viam-server` automatically restarts it:
+
+1. `viam-server` detects the exit and marks the module as failed.
+2. The machine's status changes to **initializing**.
+3. `viam-server` retries every 5 seconds.
+4. On success, `viam-server` re-adds all resources in dependency order.
+5. The machine returns to **running**.
+
+If the module keeps crashing, `viam-server` retries indefinitely. Check the
+**LOGS** tab for crash tracebacks.
+
+### Communication
+
+By default, modules communicate with `viam-server` over a Unix domain socket
+with a randomized name.
+
+TCP mode is used automatically on Windows or when the Unix socket path would
+exceed the OS limit (103 characters on macOS). Force TCP mode by setting
+`"tcp_mode": true` in the module config or setting `VIAM_TCP_SOCKETS=true`.
+
+### Data directory
+
+Every module receives a persistent data directory at
+`~/.viam/module-data///`. The path is available inside
+the module through the `VIAM_MODULE_DATA` environment variable. This directory
+persists across module restarts and reconfigurations.
+
+### Timeouts
+
+| Event | Timeout | Behavior on timeout |
+| --------------------------------------- | -------------------------------------------------------------- | ----------------------------------------- |
+| Module startup (ready check) | 5 minutes (configurable through `VIAM_MODULE_STARTUP_TIMEOUT`) | Module marked as failed; retry begins |
+| Config validation | 5 seconds | Validation fails; resource does not start |
+| Resource removal during shutdown | 20 seconds (all resources combined) | Resources orphaned |
+| Module closure (removal + process stop) | ~30 seconds total | Process killed |
+| First-run setup script | 1 hour (configurable through `first_run_timeout`) | Module startup fails |
+| Crash restart retry interval | 5 seconds | Next attempt after delay |
+
+## Resource interfaces (Go)
+
+### Config validation
+
+```go
+type ConfigValidator interface {
+ Validate(path string) (requiredDependencies, optionalDependencies []string, err error)
+}
+```
+
+Return required dependency names in the first slice. `viam-server` ensures they
+are ready before calling your constructor. Return optional dependency names in
+the second slice. These are passed to the constructor if available, but their
+absence does not block startup.
+
+### Resource interface
+
+Every resource must implement:
+
+```go
+type Resource interface {
+ Name() Name
+ Reconfigure(ctx context.Context, deps Dependencies, conf Config) error
+ DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error)
+ Close(ctx context.Context) error
+}
+```
+
+Plus the methods defined by the specific API (for example, `Readings` for sensor,
+`GetImages` for camera).
+
+### Constructor signature
+
+```go
+func(ctx context.Context, deps resource.Dependencies,
+ conf resource.Config, logger logging.Logger) (ResourceT, error)
+```
+
+### Helper traits
+
+Embed these in your resource struct to get default implementations:
+
+| Trait | Effect |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------ |
+| `resource.Named` | Interface for `Name()` and `DoCommand()`. Embed and set through `conf.ResourceName().AsNamed()`. |
+| `resource.TriviallyCloseable` | `Close()` returns nil. |
+| `resource.TriviallyReconfigurable` | `Reconfigure()` returns nil (no-op). |
+| `resource.AlwaysRebuild` | `Reconfigure()` returns `MustRebuildError` (always re-create). |
+| `resource.TriviallyValidateConfig` | `Validate()` returns no deps and no error. |
+
+### Useful functions
+
+| Function | Description |
+| --------------------------------- | ------------------------------------------------------------------------------------ |
+| `resource.NativeConfig[*T](conf)` | Convert config attributes to a typed struct. |
+| `.FromProvider(deps, name)` | Type-safe dependency lookup (for example, `sensor.FromProvider(deps, "my-sensor")`). |
+| `conf.ResourceName().AsNamed()` | Create a `Named` implementation from config. |
+| `module.ModularMain(models...)` | Convenience entry point for simple modules. |
+| `module.NewModuleFromArgs(ctx)` | Create a module from CLI args (for custom entry points). |
+| `module.NewLoggerFromArgs(name)` | Create a logger that routes to `viam-server`. |
+
+## Resource interfaces (Python)
+
+### Config validation
+
+```python
+@classmethod
+def validate_config(cls, config: ComponentConfig) -> Tuple[Sequence[str], Sequence[str]]:
+ # Return (required_deps, optional_deps)
+ return [], []
+```
+
+### Constructor
+
+```python
+@classmethod
+def new(cls, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
+ instance = cls(config.name)
+ instance.reconfigure(config, dependencies)
+ return instance
+```
+
+### Reconfigure
+
+```python
+def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ # Update internal state from new config
+ pass
+```
+
+### Close
+
+```python
+async def close(self):
+ # Clean up connections, stop background tasks, release hardware.
+ # Must be idempotent (safe to call multiple times).
+ pass
+```
+
+The default `close()` on `ResourceBase` is a no-op.
+
+### EasyResource base class
+
+For simple modules, inherit from both the API base class and `EasyResource` to
+get default `new()`, `validate_config()`, and automatic model registration:
+
+```python
+from viam.components.sensor import Sensor
+from viam.resource.easy_resource import EasyResource
+from viam.module.module import Module
+
+
+class MySensor(Sensor, EasyResource):
+ MODEL = "my-org:my-module:my-sensor"
+
+ async def get_readings(self, **kwargs):
+ return {"temperature": 23.5}
+
+
+if __name__ == '__main__':
+ import asyncio
+ asyncio.run(Module.run_from_registry())
+```
+
+### Useful functions
+
+| Function | Description |
+| --------------------------------- | ---------------------------------------------------------------- |
+| `Module.from_args()` | Create a module from CLI args. |
+| `Module.run_from_registry()` | Discover and register all imported resource classes, then start. |
+| `Module.run_with_models(*models)` | Register explicit model classes, then start. |
+| `getLogger(name)` | Create a logger (`from viam.logging import getLogger`). |
+| `config.attributes.fields` | Access raw config attributes (no typed config equivalent to Go). |
+
+### Python and Go defaults
+
+In Python, the default behavior when you don't implement a method differs from Go:
+
+| Behavior | Go | Python |
+| ------------------------ | ---------------------------------------- | ---------------------------------------------------------------------------------------- |
+| Seamless reconfigure | Implement `Reconfigure()` | Implement `reconfigure()` (called if your class satisfies the `Reconfigurable` protocol) |
+| Rebuild on config change | Embed `resource.AlwaysRebuild` | Omit `reconfigure()` (default: module destroys and re-creates the resource) |
+| No-op reconfigure | Embed `resource.TriviallyReconfigurable` | No equivalent: implement an empty `reconfigure()` instead |
+| No-op close | Embed `resource.TriviallyCloseable` | Default on `ResourceBase` |
+| Skip config validation | Embed `resource.TriviallyValidateConfig` | Default on `EasyResource` |
+
+## Logging
+
+From modules you can log at the resource level or at the machine level.
+Resource-level logging is recommended because it makes it easier to identify
+which component or service produced a message. Resource-level error logs also
+appear in the **Error logs** section of each resource's configuration card.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+# Resource-level logging (recommended):
+self.logger.debug("debug info")
+self.logger.info("info")
+self.logger.warn("warning info")
+self.logger.error("error info")
+self.logger.exception("error info", exc_info=True)
+self.logger.critical("critical info")
+```
+
+For machine-level logging instead of resource-level:
+
+```python
+from viam.logging import getLogger
+
+LOGGER = getLogger(__name__)
+
+LOGGER.debug("debug info")
+LOGGER.info("info")
+LOGGER.warn("warning info")
+LOGGER.error("error info")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (c *component) someFunction(ctx context.Context, a int) {
+ // Log with severity info:
+ c.logger.CInfof(ctx, "performing some function with a=%v", a)
+ // Log with severity debug (using value wrapping):
+ c.logger.CDebugw(ctx, "performing some function", "a" ,a)
+ // Log with severity warn:
+ c.logger.CWarnw(ctx, "encountered warning for component", "name", c.Name())
+ // Log with severity error without a parameter:
+ c.logger.CError(ctx, "encountered an error")
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+To see debug-level logs, run `viam-server` with the `-debug` flag or
+[configure debug logging](/operate/reference/viam-server/#logging) for your
+machine or individual resource.
+
+## Common gotchas
+
+**Always call `Reconfigure` from your constructor.**
+Your constructor and `Reconfigure` should share the same config-reading logic.
+The typical pattern is for the constructor to create the struct, then call
+`Reconfigure` to populate it from config. This avoids duplicating config
+parsing and ensures a newly created resource is fully configured.
+
+**Clean up in `Close()`.**
+If your resource starts background goroutines, opens connections, or holds
+hardware handles, `Close()` must stop them. Leaked goroutines accumulate across
+reconfigurations and can cause instability.
+In Python, `close()` must be idempotent (it may be called more than once).
+
+**Return the right dependency names from `Validate`.**
+Dependencies listed as required in `Validate` (Go) or `validate_config`
+(Python) must match actual resource names in the machine config. If the name
+is wrong, `viam-server` waits for a resource that will never exist, and your
+resource will not start. Use optional dependencies for resources that improve
+functionality but aren't strictly needed.
+
+**Prefer `Reconfigure` over `AlwaysRebuild`.**
+`AlwaysRebuild` (Go) or omitting `reconfigure()` (Python) causes the resource
+to be destroyed and re-created on every config change. This is simpler but
+causes a brief availability gap. Implementing `Reconfigure` to update state
+in-place provides seamless reconfiguration.
+
+## Module protocol
+
+Modules communicate with `viam-server` over gRPC using the `ModuleService`
+defined in `proto/viam/module/v1/module.proto`:
+
+All RPCs are initiated by `viam-server` and handled by the module:
+
+| RPC | Purpose |
+| --------------------- | -------------------------------------------------------- |
+| `Ready` | Handshake: module returns its supported API/model pairs. |
+| `AddResource` | Create a new resource instance from config. |
+| `ReconfigureResource` | Update an existing resource with new config. |
+| `RemoveResource` | Destroy a resource instance. |
+| `ValidateConfig` | Validate config and return implicit dependencies. |
+
+The module also connects back to the parent `viam-server` to access other
+resources (dependencies) on the machine.
+
+## meta.json schema
+
+Every module has a `meta.json` file that describes the module to the registry.
+The full schema is available at `https://dl.viam.dev/module.schema.json`.
+
+```json
+{
+ "$schema": "https://dl.viam.dev/module.schema.json",
+ "module_id": "my-org:my-module",
+ "visibility": "private",
+ "url": "https://github.com/my-org/my-module",
+ "description": "Short description of the module.",
+ "models": [
+ {
+ "api": "rdk:component:sensor",
+ "model": "my-org:my-module:my-sensor",
+ "short_description": "A short description of this model.",
+ "markdown_link": "README.md#my-sensor"
+ }
+ ],
+ "entrypoint": "run.sh",
+ "first_run": "setup.sh",
+ "markdown_link": "README.md",
+ "build": {
+ "setup": "./setup.sh",
+ "build": "./build.sh",
+ "path": "module.tar.gz",
+ "arch": ["linux/amd64", "linux/arm64"]
+ }
+}
+```
+
+| Field | Type | Required | Description |
+| ---------------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `$schema` | string | No | JSON Schema URL for editor validation. |
+| `module_id` | string | Yes | `namespace:name` or `org-id:name`. |
+| `visibility` | string | Yes | `private`, `public`, or `public_unlisted`. |
+| `url` | string | No | Source repo URL. Required for cloud builds. |
+| `description` | string | Yes | Short description shown in the registry. |
+| `models` | array | No | List of API/model pairs the module provides. Deprecated: models are now inferred from the module binary. |
+| `models[].api` | string | Yes | Resource API (for example, `rdk:component:sensor`). |
+| `models[].model` | string | Yes | Model triplet (for example, `my-org:my-module:my-sensor`). |
+| `models[].short_description` | string | No | Short model description (max 100 chars). |
+| `models[].markdown_link` | string | No | Path to model docs within the repo. |
+| `entrypoint` | string | Yes | Path to the executable inside the archive. |
+| `first_run` | string | No | Path to a one-time setup script. Runs before the entrypoint on first install and after version updates. See [First-run scripts](#first-run-scripts). |
+| `markdown_link` | string | No | Path to README used as registry description. |
+| `build` | object | No | Build configuration for local and cloud builds. |
+| `build.setup` | string | No | One-time setup command (for example, install dependencies). |
+| `build.build` | string | No | Build command (for example, `make module.tar.gz`). |
+| `build.path` | string | No | Path to built artifact. Default: `module.tar.gz`. |
+| `build.arch` | array | No | Target platforms. Default: `["linux/amd64", "linux/arm64"]`. |
+| `build.darwin_deps` | array | No | Homebrew dependencies for macOS builds (for example, `["go", "pkg-config"]`). |
+| `applications` | array | No | Viam applications provided by the module. See [Applications](#applications). |
+
+### Applications
+
+If your module provides a [Viam application](/operate/control/viam-applications/),
+define it in the `applications` array in `meta.json`.
+
+Each application object has the following properties:
+
+| Property | Type | Description |
+| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `name` | string | The application name, used in the application's URL (`name_publicnamespace.viamapps.com`). Must be all-lowercase, alphanumeric and hyphens only, cannot start or end with a hyphen, and must be unique within your organization's namespace. |
+| `type` | string | `"single_machine"` or `"multi_machine"`. Whether the application can access one machine or multiple machines. |
+| `entrypoint` | string | Path to the HTML entry point (for example, `"dist/index.html"`). |
+| `fragmentIds` | []string | Fragment IDs a machine must contain to be selectable from the machine picker. Single-machine applications only. |
+| `logoPath` | string | URL or relative path to the logo for the machine picker screen. Single-machine applications only. |
+| `customizations` | object | Override branding on the authentication screen. Contains a `machinePicker` object with properties: `heading` (max 60 chars), `subheading` (max 256 chars). |
+
+For example, if your organization namespace is `acme` and your application name
+is `dashboard`, your application is accessible at:
+
+```txt
+https://dashboard_acme.viamapps.com
+```
+
+### Organization namespace
+
+When uploading modules to the Viam Registry, you must set a unique namespace
+for your organization.
+
+**Create a namespace:** In the Viam app, click your organization name in the
+top navigation bar, then click **Settings**, then click **Set a public namespace**. Enter a
+name and click **Set namespace**. Namespaces may only contain letters, numbers,
+and hyphens (`-`).
+
+**Rename a namespace:**
+
+1. Navigate to your organization settings page.
+2. Click **Rename** next to your current namespace.
+3. Enter the new namespace name and click **Rename**.
+4. Update the module code and `meta.json` for each module your organization owns
+ to reflect the new namespace.
+5. (Recommended) Update the `model` field in machine configurations that
+ reference the old namespace. Old references continue to work, but updating
+ avoids confusion.
+
+When you rename a namespace, Viam reserves the old namespace for backwards
+compatibility: it cannot be reused.
+
+## CLI commands
+
+All module CLI commands are under `viam module`. You must be logged in
+(`viam login`) to use commands that interact with the registry.
+
+### Create and generate
+
+| Command | Description |
+| ---------------------------------- | ------------------------------------------------------------------------ |
+| `viam module create --name ` | Register a module in the registry and generate `meta.json`. |
+| `viam module generate` | Scaffold a complete module project with templates (interactive prompts). |
+
+`generate` flags: `--name`, `--language` (`python` or `go`), `--visibility`,
+`--public-namespace`, `--resource-subtype`, `--model-name`, `--register`,
+`--dry-run`.
+
+### Build
+
+| Command | Description |
+| -------------------------------------------- | ------------------------------------------------- |
+| `viam module build local` | Run the build command from `meta.json` locally. |
+| `viam module build start --version ` | Start a cloud build for all configured platforms. |
+| `viam module build list` | List cloud build jobs and their status. |
+| `viam module build logs --id ` | Stream logs from a cloud build job. |
+
+`build start` flags: `--ref` (git ref, default: `main`), `--platforms`,
+`--token` (for private repos), `--workdir`.
+
+During builds, the environment variables `VIAM_BUILD_OS` and `VIAM_BUILD_ARCH`
+are set to the target platform. See [Environment variables](#environment-variables).
+
+### Upload and update
+
+| Command | Description |
+| ------------------------------------------------------------- | -------------------------------------------------------- |
+| `viam module upload --version --platform ` | Upload a built archive to the registry. |
+| `viam module update` | Push updated `meta.json` to the registry. |
+| `viam module update-models --binary ` | Auto-detect models from a binary and update `meta.json`. |
+| `viam module download --id ` | Download a module from the registry. |
+
+`upload` flags: `--tags` (platform constraints), `--force` (skip validation),
+`--upload` (path to archive).
+
+### Development loop
+
+| Command | Description |
+| ----------------------------------------- | ----------------------------------------------------------- |
+| `viam module reload-local --part-id ` | Build locally, transfer to machine, configure, and restart. |
+| `viam module reload --part-id ` | Build in cloud; machine downloads the package directly. |
+| `viam module restart --part-id ` | Restart a running module without rebuilding. |
+
+`reload-local` flags: `--part-id` (target machine part), `--no-build` (skip
+build), `--local` (run entrypoint directly on localhost instead of bundling),
+`--model-name` (add a resource to config with this model triple),
+`--name` (name the added resource), `--resource-name` (name the resource
+instance), `--id` (module ID, alternative to `--name`),
+`--cloud-config` (path to `viam.json`, alternative to `--part-id`),
+`--workdir` (subdirectory containing `meta.json`), `--home-dir` (remote
+user's home directory), `--no-progress` (hide transfer progress).
+
+## Environment variables
+
+### Runtime
+
+These environment variables are available inside a running module process
+(including [first-run scripts](#first-run-scripts)):
+
+| Variable | Description |
+| ------------------ | ----------------------------------------------- |
+| `VIAM_MODULE_NAME` | The module's name from config. |
+| `VIAM_MODULE_DATA` | Path to the module's persistent data directory. |
+| `VIAM_MODULE_ROOT` | Parent directory of the module executable. |
+| `VIAM_MODULE_ID` | Registry module ID (registry modules only). |
+| `VIAM_HOME` | Path to the Viam home directory (`~/.viam`). |
+
+### Cloud-connected
+
+When the machine is connected to Viam Cloud, these additional variables are
+available inside the module process:
+
+| Variable | Description |
+| ---------------------- | ------------------------------------------------------ |
+| `VIAM_API_KEY` | API key (if an API key auth handler is configured). |
+| `VIAM_API_KEY_ID` | API key ID (if an API key auth handler is configured). |
+| `VIAM_MACHINE_ID` | Cloud machine ID. |
+| `VIAM_MACHINE_PART_ID` | Cloud machine part ID. |
+| `VIAM_MACHINE_FQDN` | Machine's fully qualified domain name. |
+| `VIAM_LOCATION_ID` | Cloud location ID. |
+| `VIAM_PRIMARY_ORG_ID` | Primary organization ID. |
+
+Custom environment variables can be added in the module's machine config under
+the `env` field.
+
+### Build-time
+
+The following variables are set during [cloud builds](#build), not at runtime:
+
+| Variable | Description |
+| ----------------- | --------------------------------------------------------- |
+| `VIAM_BUILD_OS` | Target operating system (for example, `linux`, `darwin`). |
+| `VIAM_BUILD_ARCH` | Target architecture (for example, `amd64`, `arm64`). |
+
+### Server-side
+
+The following variables control `viam-server` startup behavior (not passed to modules):
+
+| Variable | Description |
+| ------------------------------------- | -------------------------------------------------------------------------- |
+| `VIAM_MODULE_STARTUP_TIMEOUT` | Override the default 5-minute startup timeout (for example, `10m`, `30s`). |
+| `VIAM_RESOURCE_CONFIGURATION_TIMEOUT` | Override the default 2-minute per-resource configuration timeout. |
+
+## Supported platforms
+
+| Platform | Cloud build support |
+| --------------- | ----------------------------------------- |
+| `linux/amd64` | Yes |
+| `linux/arm64` | Yes |
+| `linux/arm32v6` | No |
+| `linux/arm32v7` | No |
+| `darwin/amd64` | No |
+| `darwin/arm64` | Yes |
+| `windows/amd64` | Yes |
+| `any` | No (use for platform-independent modules) |
+
+## Registry validation rules
+
+| Rule | Constraint |
+| ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| Module name | 1-200 characters, `^[a-zA-Z0-9][-\w]*$` (must start with alphanumeric; may contain hyphens, underscores, letters, digits) |
+| Module version | Semantic versioning 2.0.0 (for example, `1.2.3`) |
+| Package name | `^[\w-]+$` |
+| Metadata fields | Max 16 key-value pairs |
+| Metadata key/value size | Max 500 KB each |
+| Compressed package | Max 50 GB |
+| Decompressed contents | Max 250 GB |
+| Single file in package | Max 25 GB |
+| Model namespace | Must match org namespace if org has one. Cannot use reserved namespace `rdk`. |
+| Public modules | Require org to have a public namespace. Cannot use `public_unlisted` → `private` if external orgs are using the module. |
diff --git a/docs/build-modules/overview.md b/docs/build-modules/overview.md
new file mode 100644
index 0000000000..ca9b4c71fe
--- /dev/null
+++ b/docs/build-modules/overview.md
@@ -0,0 +1,208 @@
+---
+linkTitle: "Overview"
+title: "Build and deploy modules"
+weight: 1
+layout: "docs"
+type: "docs"
+description: "Understand the two kinds of modules and how to extend your machine with custom hardware drivers and application logic."
+aliases:
+ - /build-modules/from-hardware-to-logic/
+---
+
+Modules extend what your machine can do. They come in two varieties: driver
+modules that add support for new hardware, and logic modules that tie
+components together with decision-making code. You can develop modules in your
+own IDE or write them directly in the browser using
+[inline modules](#inline-and-externally-managed-modules).
+
+If you have already [configured components](/hardware/configure-hardware/) on
+your machine, each one works individually: you can test it from the Viam app,
+capture its data, and call its API from a script. The next step is making
+them work **together**. A camera detects an object, and a motor responds. A
+temperature sensor crosses a threshold, and a notification fires. A movement
+sensor reports position, and an arm adjusts.
+
+## Two kinds of modules
+
+### Driver modules: add hardware support
+
+A [driver module](/build-modules/write-a-driver-module/) teaches Viam how to
+talk to a specific piece of hardware. Every module implements one of Viam's
+resource APIs. For a driver module, you pick the component API that matches
+your hardware:
+
+| API | Use when your hardware... | Key methods |
+| -------- | --------------------------------------------------- | ---------------------------------------- |
+| `sensor` | Produces readings (temperature, distance, humidity) | `GetReadings` |
+| `camera` | Produces images or point clouds | `GetImage`, `GetPointCloud` |
+| `motor` | Drives rotational or linear motion | `SetPower`, `GoFor`, `Stop` |
+| `arm` | Has joints and moves to poses | `MoveToPosition`, `MoveToJointPositions` |
+| `base` | Is a mobile platform (wheeled, tracked, legged) | `MoveStraight`, `Spin`, `SetVelocity` |
+
+Viam defines over 15 component APIs and 10 service APIs. For the full list,
+see [Resource APIs](/reference/apis/).
+
+Each implementation of a resource API is called a **model**. For example,
+the `camera` API has models for USB cameras, CSI cameras, RTSP streams, and
+others. When no existing model supports your hardware,
+you write a driver module to add one. Once it exists, the hardware behaves
+like any built-in component. Data capture, test panels, and the SDKs work
+automatically.
+
+### Logic modules: sense and act
+
+A [logic module](/build-modules/write-a-logic-module/) controls your machine's
+behavior. It declares dependencies on the resources it needs and implements
+your application's decision-making. Many logic modules run continuously on
+your machine, reading from sensors, evaluating conditions, and commanding
+actuators.
+
+Use a logic module when you need your machine to:
+
+- **React to sensor data**: trigger an alert or an actuator when a reading
+ crosses a threshold.
+- **Coordinate multiple components**: read from a camera and command an arm
+ based on what the camera sees.
+- **Run continuous processes**: monitor, aggregate, or transform data from
+ multiple sources.
+- **Schedule actions**: perform operations at specific intervals or times.
+
+Logic modules typically implement the `generic` service API. The generic API
+has a single method, `DoCommand`, which accepts and returns arbitrary key-value
+maps. Use it to check status, adjust parameters, or send commands to your
+running module from external scripts or the Viam app:
+
+```json
+// Request
+{"command": "get_alerts", "severity": "critical"}
+
+// Response
+{"alerts": [{"sensor": "temp-1", "value": 42.5, "threshold": 40.0}]}
+```
+
+Use `generic` when your module's interface does not map to an existing service
+API (like `vision` or `mlmodel`).
+
+## Inline and externally managed modules
+
+There are two ways to develop and deploy modules:
+
+**Inline modules** let you write code directly in the Viam app's
+browser-based editor. Viam manages source code, builds, versioning, and
+deployment. When you click **Save & Deploy**, the module builds in the cloud
+and deploys to your machine automatically. Inline modules are the fastest way
+to get started, especially for prototyping and simple control logic.
+
+**Externally managed modules** are modules you develop in your own IDE, manage
+in your own git repository, and deploy through the Viam CLI or GitHub Actions.
+Use externally managed modules when you need your own source control, public
+distribution, or custom build pipelines.
+
+| | Inline | Externally managed |
+| ------------------------ | --------------------------------- | -------------------------------------------- |
+| **Where you write code** | Browser editor in the Viam app | Your own IDE, locally or in a repo |
+| **Source control** | Managed by Viam | Your own git repository |
+| **Build system** | Automatic cloud builds on save | Cloud build (GitHub Actions) or local builds |
+| **Versioning** | Automatic (`0.0.1`, `0.0.2`, ...) | You choose semantic versions |
+| **Visibility** | Private to your organization | Private or public |
+
+Both types run identically at runtime, as child processes communicating with
+`viam-server` over gRPC.
+
+## Module lifecycle
+
+Every module goes through a defined lifecycle:
+
+1. **Startup** -- `viam-server` launches the module as a separate process. The
+ module registers its models and opens a gRPC connection back to the server.
+2. **Validation** -- For each configured resource, `viam-server` calls the
+ model's config validation method to check attributes and declare
+ dependencies.
+3. **Creation** -- If validation passes, `viam-server` calls the model's
+ constructor with the resolved dependencies.
+4. **Reconfiguration** -- If the user changes the configuration, `viam-server`
+ calls the validation method again, then the reconfiguration method.
+5. **Shutdown** -- `viam-server` calls the resource's close method. Clean up
+ resources here.
+
+For the full lifecycle reference including crash recovery, first-run scripts,
+and timeouts, see [Module developer reference](/build-modules/module-reference/#module-lifecycle).
+
+## Attributes
+
+Attributes are the user-provided configuration for your resource. When someone
+adds your module to a machine, they set attributes in the Viam app or in the
+machine's JSON config. Examples include a device address for a driver module,
+or a polling interval and threshold for a logic module.
+
+Your module defines which attributes it expects and validates them in its
+config validation method. If validation fails, `viam-server` reports the error
+and does not create the resource. Attributes are passed to your constructor
+when the resource is created and again to your reconfiguration method when
+the configuration changes.
+
+For code examples, see the attribute definitions in
+[Write a driver module](/build-modules/write-a-driver-module/#define-your-config-attributes)
+and [Write a logic module](/build-modules/write-a-logic-module/).
+
+## Dependencies
+
+Dependencies let your resource use other resources on the same machine. You
+declare dependencies in your config validation method by returning the names of
+resources your module needs. `viam-server` resolves these, ensures the
+depended-on resources are ready, and passes them to your constructor.
+
+- **Required** dependencies must be running before your resource starts.
+- **Optional** dependencies let your resource start immediately; `viam-server`
+ retries every 5 seconds and reconfigures your resource when the dependency
+ becomes available.
+
+The pattern has three steps:
+
+1. **Declare** -- return dependency names from your validation method.
+2. **Resolve** -- look up each dependency from the map in your constructor.
+3. **Use** -- call methods on the resolved dependencies in your logic.
+
+For detailed code examples, see
+[Module dependencies](/build-modules/dependencies/).
+
+## The module registry
+
+The Viam module registry stores versioned module packages and serves them to
+machines on demand. When you configure a module on a machine, `viam-server`
+downloads the correct version for the machine's platform (OS and architecture).
+
+Modules can be:
+
+- **Private** -- visible only to your organization.
+- **Public** -- visible to all Viam users.
+- **Unlisted** -- usable by anyone who knows the module ID, but not shown in
+ registry search results.
+
+The registry uses semantic versioning. Machines can track the latest version
+(automatic updates) or pin to a specific version.
+
+## Background tasks
+
+Logic modules often need to run continuously: polling sensors, checking
+thresholds, updating state. You can spawn background tasks (goroutines in Go,
+async tasks in Python) from your constructor or reconfiguration method.
+
+The key requirement: your background task must stop cleanly when the module
+shuts down or reconfigures. Use a cancellation signal (a context cancellation
+in Go, an `asyncio.Event` in Python) to coordinate this.
+
+## How it fits together
+
+1. **Configure components** ([Configure hardware](/hardware/configure-hardware/)).
+ Your machine can sense and act through individual hardware.
+2. **Test interactively**. Use test panels in the Viam app and SDK scripts
+ to verify each component works.
+3. **Capture data** ([Capture and sync data](/data/capture-sync/capture-and-sync-data/)).
+ Start recording what your sensors observe.
+4. **Write a module**. Tie components together with decision-making code, or
+ add support for new hardware.
+5. **Deploy** ([Deploy a module](/build-modules/deploy-a-module/)).
+ Package your module and deploy it to one machine or a fleet.
+
+If you want to build a client app that talks to a machine from outside `viam-server` rather than extending the server itself, see [Build apps](/build-apps/).
diff --git a/docs/build-modules/platform-apis.md b/docs/build-modules/platform-apis.md
new file mode 100644
index 0000000000..76a04e5643
--- /dev/null
+++ b/docs/build-modules/platform-apis.md
@@ -0,0 +1,284 @@
+---
+title: "Access platform APIs from within a module"
+linkTitle: "Use platform APIs"
+weight: 35
+layout: "docs"
+type: "docs"
+description: "Write your validate and reconfigure functions to handle dependencies in your custom modular resource."
+aliases:
+date: "2025-11-05"
+---
+
+To use the platform or machine APIs, you must authenticate using API keys.
+
+- [Use platform APIs from a module](#use-platform-apis-from-a-module)
+- [Use the machine management API from a module](#use-the-machine-management-api-from-a-module)
+
+## Use platform APIs from a module
+
+The following steps show you how to use the following APIs from a module:
+
+- [Fleet management (`app_client`)](/reference/apis/fleet/)
+- [Data client (`data_client`)](/reference/apis/data-client/)
+- [ML training (`ml_training_client`)](/reference/apis/ml-training-client/)
+- [Billing (`billing_client`)](/reference/apis/billing-client/)
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+1. Add the following imports:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ import os
+ from viam.app.viam_client import ViamClient
+ from viam.app.app_client import AppClient
+ from viam.app.data_client import DataClient
+ from viam.app.ml_training_client import MLTrainingClient
+ from viam.app.billing_client import BillingClient
+ ```
+
+1. Add the `viam_client` and other clients to the resource class:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ class TestSensor(Sensor, EasyResource):
+ viam_client: Optional[ViamClient] = None
+ app_client: Optional[AppClient] = None
+ data_client: Optional[DataClient] = None
+ ml_training_client: Optional[MLTrainingClient] = None
+ billing_client: Optional[BillingClient] = None
+
+ # ...
+ ```
+
+1. Initialize the clients and use them:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ async def some_module_function(self):
+ # Ensure there is only one viam_client connection
+ if not self.viam_client:
+ self.viam_client = await ViamClient.create_from_env_vars()
+
+ self.app_client = self.viam_client.app_client
+ self.data_client = self.viam_client.data_client
+ self.ml_training_client = self.viam_client.ml_training_client
+ self.billing_client = self.viam_client.billing_client
+ # Use the clients in your module
+ locations = await self.app_client.list_locations(os.environ.get("VIAM_PRIMARY_ORG_ID"))
+ ```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+1. Add the following imports:
+
+ ```go {class="line-numbers linkable-line-numbers"}
+ "os"
+ "go.viam.com/rdk/app"
+ ```
+
+1. Add the `viam_client` and other clients to the resource class:
+
+ ```go {class="line-numbers linkable-line-numbers"}
+ type testPlatformApisGoModuleTestDataClient struct {
+ resource.AlwaysRebuild
+
+ name resource.Name
+
+ logger logging.Logger
+ cfg *Config
+
+ cancelCtx context.Context
+ cancelFunc func()
+
+ viamClient *app.ViamClient
+ appClient *app.AppClient
+ dataClient *app.DataClient
+ mlTrainingClient *app.MLTrainingClient
+ billingClient *app.BillingClient
+ }
+ ```
+
+1. Initialize the clients and use them:
+
+ ```go {class="line-numbers linkable-line-numbers"}
+ func (s *exampleModuleResource) SomeModuleFunction(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) {
+ if s.viamClient == nil {
+ var err error
+ s.viamClient, err = app.CreateViamClientFromEnvVars(ctx, &app.Options{}, s.logger)
+ if err != nil {
+ return nil, err
+ }
+ s.appClient = s.viamClient.AppClient()
+ s.dataClient = s.viamClient.DataClient()
+ s.mlTrainingClient = s.viamClient.MLTrainingClient()
+ s.billingClient = s.viamClient.BillingClient()
+ }
+ locations, err := s.appClient.ListLocations(ctx, os.Getenv("VIAM_PRIMARY_ORG_ID"))
+ if err != nil {
+ return nil, err
+ }
+
+ // Use locations...
+ return map[string]interface{}{"location_count": len(locations)}, nil
+
+ }
+ ```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Elevate access
+
+The [module environment variables](/reference/) `VIAM_API_KEY` and `VIAM_API_KEY_ID` provide [machine owner access](/organization/rbac/) for the machine the module is running on.
+
+If you need a higher level of access, you can pass API keys as part of the module configuration:
+
+1. Create an API key with the appropriate [permissions](/organization/rbac/) from your organization settings page.
+1. Add the API key and API key ID values to the module configuration:
+
+ ```json
+ {
+ "modules": [
+ {
+ "type": "registry",
+ "name": "example-module",
+ "module_id": "naomi:example-module",
+ "version": "latest",
+ "env": {
+ "VIAM_API_KEY": "abcdefg987654321abcdefghi",
+ "VIAM_API_KEY_ID": "1234abcd-123a-987b-1234567890abc"
+ }
+ }
+ ]
+ }
+ ```
+
+ This changes the environment variables `VIAM_API_KEY` and `VIAM_API_KEY_ID` from the default to the provided ones.
+
+## Use the machine management API from a module
+
+To use the [machine management (`robot_client`) API](/reference/apis/robot/), you must get the machine's FQDN and API keys from the module environment variables.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+1. Add the following imports and the `create_robot_client_from_module` method:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ # Add imports
+ import os
+ from viam.robot.client import RobotClient
+
+ # For robot client, you can also use the machine's FQDN:
+ async def create_robot_client_from_module():
+ # Get API credentials from module environment variables
+ api_key = os.environ.get("VIAM_API_KEY")
+ api_key_id = os.environ.get("VIAM_API_KEY_ID")
+ machine_fqdn = os.environ.get("VIAM_MACHINE_FQDN")
+
+ if not api_key or not api_key_id or not machine_fqdn:
+ raise Exception("VIAM_API_KEY, VIAM_API_KEY_ID, and " +
+ "VIAM_MACHINE_FQDN " +
+ "environment variables are required")
+
+ # Create robot client options with API key authentication
+ opts = RobotClient.Options.with_api_key(
+ api_key=api_key,
+ api_key_id=api_key_id
+ )
+
+ # Create RobotClient using the machine's FQDN
+ robot_client = await RobotClient.at_address(machine_fqdn, opts)
+
+ return robot_client
+ ```
+
+1. Add the `robot_client` or other clients to the resource class:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ class TestSensor(Sensor, EasyResource):
+ robot_client: Optional[RobotClient] = None
+ # ...
+ ```
+
+1. Initialize the client and use it:
+
+ ```python {class="line-numbers linkable-line-numbers"}
+ async def some_module_function(self):
+ # Ensure there is only one robot client
+ if not self.robot_client:
+ self.robot_client = await create_robot_client_from_module()
+ # Use the robot client
+ resources = [str(name) for name in self.robot_client.resource_names]
+ ```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+1. Add the following imports and the `createRobotClientFromModule` function:
+
+ ```go {class="line-numbers linkable-line-numbers"}
+ "os"
+ "go.viam.com/rdk/robot/client"
+ "go.viam.com/utils/rpc"
+
+ func createRobotClientFromModule(ctx context.Context, logger logging.Logger) (*client.RobotClient, error) {
+ robotClient, err := client.New(
+ ctx,
+ os.Getenv("VIAM_MACHINE_FQDN"),
+ logger,
+ client.WithDialOptions(rpc.WithEntityCredentials(
+ os.Getenv("VIAM_API_KEY_ID"),
+ rpc.Credentials{
+ Type: rpc.CredentialsTypeAPIKey,
+ Payload: os.Getenv("VIAM_API_KEY"),
+ })),
+ )
+ if err != nil {
+ return nil, err
+ }
+ return robotClient, nil
+ }
+ ```
+
+1. Add the `viam_client` and other clients to the resource class:
+
+ ```go {class="line-numbers linkable-line-numbers"}
+ type testPlatformApisGoModuleTestDataClient struct {
+ resource.AlwaysRebuild
+
+ name resource.Name
+
+ logger logging.Logger
+ cfg *Config
+
+ cancelCtx context.Context
+ cancelFunc func()
+
+ machine *client.RobotClient
+ }
+ ```
+
+1. Initialize the clients and use them:
+
+```go {class="line-numbers linkable-line-numbers"}
+func (s *exampleModuleResource) SomeModuleFunction(ctx context.Context, extra map[string]interface{}) (map[string]interface{}, error) {
+ if s.machine == nil {
+ var err error
+ s.machine, err = createRobotClientFromModule(ctx, s.logger)
+ if err != nil {
+ return nil, err
+ }
+ }
+ resources := s.machine.ResourceNames()
+
+ // Use resources...
+ return map[string]interface{}{"resource_count": len(resources)}, nil
+
+}
+```
+
+{{% /tab %}}
+{{% /tabs %}}
+
+To elevate access for the machine management API, follow the same steps described in [Elevate access](#elevate-access) above.
diff --git a/docs/build-modules/write-a-cpp-module.md b/docs/build-modules/write-a-cpp-module.md
new file mode 100644
index 0000000000..2b875d645f
--- /dev/null
+++ b/docs/build-modules/write-a-cpp-module.md
@@ -0,0 +1,1685 @@
+---
+title: "Create a new module with C++"
+linkTitle: "Create a C++ module"
+type: "docs"
+weight: 27
+images: ["/registry/module-puzzle-piece.svg"]
+tags: ["modular resources", "components", "services", "registry"]
+description: "Add support for a new component or service model by writing a module in C++."
+languages: ["c++"]
+viamresources: []
+platformarea: ["registry"]
+level: "Intermediate"
+date: "2024-07-30"
+# updated: "" # When the tutorial was last entirely checked
+cost: "0"
+draft: true # Take out Go and Python, and check updatedness before un-drafting.
+---
+
+Viam provides built-in support for a variety of different {{< glossary_tooltip term_id="component" text="components" >}} and {{< glossary_tooltip term_id="service" text="services" >}}, as well as a registry full of {{< glossary_tooltip term_id="module" text="modules" >}} created by other users.
+If no [existing modules](/hardware/configure-hardware/) support your specific use case, you can write your own custom modular {{< glossary_tooltip term_id="resource" text="resources" >}} by creating a module, and either upload it to the [registry](https://app.viam.com/registry) to share it publicly, or deploy it to your machine as a local module without uploading it to the registry.
+
+Follow the instructions below to learn how to write a new module using your preferred language and its corresponding [Viam SDK](/reference/sdks/), and then deploy it to your machines.
+
+{{< alert title="Note: viam-micro-server modules" color="note" >}}
+[`viam-micro-server`](/operate/reference/viam-micro-server/) works differently from the RDK (and `viam-server`), so creating modular resources for it is different from the process described on this page.
+Refer to the [Micro-RDK Module Template on GitHub](https://github.com/viamrobotics/micro-rdk/tree/main/templates/module) for information on how to create custom resources for your `viam-micro-server` machine.
+You will need to [recompile and flash your ESP32 yourself](/operate/install/setup/) instead of using Viam's prebuilt binary and installer.
+{{< /alert >}}
+
+{{% alert title="Tip" color="tip" %}}
+For a simplified step-by-step guide, see [Create a Hello World module](/build-modules/write-a-driver-module/).
+{{% /alert %}}
+
+You can also watch this guide to creating a vision service module:
+
+{{}}
+
+## Write a module
+
+Generally, to write a module, you will complete the following steps:
+
+1. Choose a Viam API to implement in your model.
+1. Write your new model definition code to map all the capabilities of your model to the API.
+1. Write an entry point (main program) file that registers your model with the Viam SDK and starts it up.
+1. Compile or package the model definition file or files, main program file, and any supporting files into a single executable file (a module) that can be run by `viam-server`.
+
+While you can certainly combine the resource model definition and the main program code into a single file if desired (for example, a single `main.py` program that includes both the model definition and the `main()` program that uses it), this guide will use separate files for each.
+
+### Choose an API to implement in your model
+
+Look through the [component APIs](/reference/apis/#component-apis) and [service API](/reference/apis/#service-apis) and find the API that best fits your use case.
+Each API contains various methods which you will need to define in your module:
+
+- One or more methods specific to that API, such as the servo component's `Move` and `GetPosition` methods.
+- Inherited `ResourceBase` methods such as `DoCommand` and `Close`, common to all Viam {{< glossary_tooltip term_id="resource" text="resource" >}} APIs.
+- (For some APIs) Other inherited methods, for example all actuator APIs such as the motor API and servo API inherit `IsMoving` and `Stop`.
+
+{{< expand "Click for more guidance" >}}
+Think about what functionality you want your module to provide, what methods you need, and choose an API to implement accordingly.
+For example, the [sensor API](/reference/apis/components/sensor/) has a `GetReadings` method, so if you create a module for a model of sensor, you'll need to write code to provide a response to the `GetReadings` method.
+If instead of just getting readings, you actually have an encoder and need to be able to reset the zero position, use the [encoder API](/reference/apis/components/encoder/) so you can define functionality behind the `GetPosition` and `ResetPosition` methods.
+
+In addition to the list of methods, another reason to choose one API over another is how certain APIs fit into the Viam ecosystem.
+For example, though you could technically implement a GPS as a sensor with just the `GetReadings` method, if you implement it as a movement sensor then you have access to methods like `GetCompassHeading` which allow you to use your GPS module with the [navigation service](/operate/reference/services/navigation/).
+For this reason, it's generally best to choose the API that most closely matches your hardware or software.
+{{< /expand >}}
+
+{{% alert title=Note color="note" %}}
+If you want to write a module to add support to a new type of component or service that is relatively unique, consider using the generic API for your resource type to build your own API:
+
+- If you are working with a component that doesn't fit into any of the existing component APIs, you can use the [generic component](/operate/reference/components/generic/) to build your own component API.
+- If you are designing a service that doesn't fit into any of the existing service APIs, you can use the [generic service](/operate/reference/components/generic/) to build your own service API.
+- It is also possible to [define an entirely new API](/operate/reference/create-subtype/), but this is even more advanced than using `generic`.
+
+Most module use cases, however, benefit from implementing an existing API instead of `generic`.
+{{% /alert %}}
+
+#### Valid API identifiers
+
+Each existing component or service API has a unique identifier in the form of a colon-delimited triplet.
+You will use this {{< glossary_tooltip term_id="api-namespace-triplet" text="API namespace triplet" >}} when creating your new model, to indicate which API it uses.
+
+The API namespace triplet is the same for all built-in and modular models that implement a given API.
+For example, every model of motor built into Viam, as well as every custom model of motor provided by a module, all use the same API namespace triplet `rdk:component:motor` to indicate that they implement the [motor API](/operate/reference/components/motor/#api).
+
+The three pieces of the API namespace triplet are as follows:
+
+{{< tabs >}}
+{{% tab name="Component" %}}
+
+- `namespace`: `rdk`
+- `type`: `component`
+- `subtype`: any one of [these component proto files](https://github.com/viamrobotics/api/tree/main/proto/viam/component), for example `motor` if you are creating a new model of motor
+
+{{% /tab %}}
+{{% tab name="Service" %}}
+
+- `namespace`: `rdk`
+- `type`: `service`
+- `subtype`: any one of [these service proto files](https://github.com/viamrobotics/api/tree/main/proto/viam/service), for example `vision` if you are creating a new model of vision service
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Name your new resource model
+
+In addition to determining which existing API namespace triplet to use when creating your module, you need to decide on a separate triplet unique to your model.
+
+{{< expand "API namespace triplet and model namespace triplet example" >}}
+
+The `rand:yahboom:arm` model and the `rand:yahboom:gripper` model use the module name (and matching repo name) [yahboom](https://github.com/viam-labs/yahboom).
+The models implement the `rdk:component:arm` and the `rdk:component:gripper` API to support the Yahboom DOFBOT arm and gripper, respectively:
+
+```json
+{
+ "api": "rdk:component:arm",
+ "model": "rand:yahboom:arm"
+},
+{
+ "api": "rdk:component:gripper",
+ "model": "rand:yahboom:gripper"
+}
+```
+
+{{< /expand >}}
+
+A resource model is identified by a unique name, called the {{< glossary_tooltip term_id="model-namespace-triplet" text="model namespace triplet" >}}, using the format: `namespace:module-name:model-name`, where:
+
+- `namespace` is the [namespace of your organization](/build-modules/module-reference/#organization-namespace).
+ - For example, if your organization uses the `acme` namespace, your models must all begin with `acme`, like `acme:module-name:mybase`.
+ If you do not intend to [upload your module](#upload-your-module-to-the-modular-resource-registry) to the [registry](https://app.viam.com/registry), you do not need to use your organization's namespace as your model's namespace.
+ - The `viam` namespace is reserved for models provided by Viam.
+- `module-name` is the name of your module.
+ Your `module-name` should describe the common functionality provided across the model or models provided by that module.
+ - Many people also choose to use the module name as the name of the code repository (GitHub repo) that houses the module code.
+- `model-name` is the name of the new resource model that your module will provide.
+
+For example, if your organization namespace is `acme`, and you have written a new base implementation named `mybase` which you have supported with a module named `my-custom-base-module`, you would use the namespace `acme:my-custom-base-module:mybase` for your model.
+
+More requirements:
+
+- Your model triplet must be all-lowercase.
+- Your model triplet may only use alphanumeric (`a-z` and `0-9`), hyphen (`-`), and underscore (`_`) characters.
+
+Determine the model name you want to use based on these requirements, then proceed to the next section.
+
+### Write your new resource model definition
+
+In this step, you will code the logic that is unique to your model.
+
+{{% alert title="Tip (optional)" color="tip" %}}
+
+If you are using Golang, use the [Golang Module templates](https://github.com/viam-labs/module-templates-golang) which contain detailed instructions for creating your module.
+
+If you are using Python, you can use the [Viam module generator](https://github.com/viam-labs/generator-viam-module/) to generate the scaffolding for a module with one resource model.
+
+{{% /alert %}}
+
+{{< expand "Additional example modules" >}}
+
+Browse additional example modules by language:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+
+| Module | Repository | Description |
+| ------ | ---------- | ----------- |
+| [berryimu](https://app.viam.com/module/viam-labs/berryimu) | [viam-labs/berry-imu](https://github.com/viam-labs/berry-imu) | Extends the built-in [movement sensor API](/reference/apis/components/movement-sensor/) to support using the BerryIMU v3 accelerometer, gyroscope and magnetometer using an I2C connection on ARM64 systems. |
+| [oak](https://app.viam.com/module/viam/oak) | [viam-modules/viam-camera-oak](https://github.com/viam-modules/viam-camera-oak) | Extends the built-in [camera API](/reference/apis/components/camera/) to support OAK cameras. |
+| [odrive](https://app.viam.com/module/viam/odrive) | [viamrobotics/odrive](https://github.com/viamrobotics/odrive) | Extends the built-in [motor API](/operate/reference/components/motor/#api) to support the ODrive motor. This module provides two models, one for a `canbus`-connected ODrive motor, and one for a `serial`-connected ODrive motor. |
+| [yahboom](https://app.viam.com/module/rand/yahboom) | [viamlabs/yahboom](https://github.com/viam-labs/yahboom) | Extends the built-in [arm API](/reference/apis/components/arm/) and [gripper API](/reference/apis/components/gripper/) to support the Yahboom Dofbot robotic arm. |
+
+For more Python module examples:
+
+- See the [Python SDK `examples` directory](https://github.com/viamrobotics/viam-python-sdk/tree/main/examples) for sample module code of varying complexity.
+- For an example featuring a sensor, see [MCP300x](https://github.com/viam-labs/mcp300x-adc-sensor).
+- For additional examples use the [modular resources search](https://app.viam.com/registry) to search for examples of the model you are implementing, and click on the model's link to be able to browse its code.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+
+| Module | Repository | Description |
+| ------ | ---------- | ----------- |
+| [agilex-limo](https://app.viam.com/module/viam/agilex-limo) | [viamlabs/agilex](https://github.com/viam-labs/agilex/) | Extends the built-in [base API](/reference/apis/components/base/) to support the Agilex Limo base. |
+| [rplidar](https://app.viam.com/module/viam/rplidar) | [viamrobotics/rplidar](https://github.com/viamrobotics/rplidar) | Extends the built-in [camera API](/reference/apis/components/camera/) to support several models of the SLAMTEC RPlidar. |
+| [filtered-camera](https://app.viam.com/module/viam/filtered-camera) | [viamrobotics/filtered-camera](https://app.viam.com/module/viam/filtered-camera) | Extends the built-in [camera API](/reference/apis/components/camera/) to enable filtering captured images by comparing to a defined ML model, and only syncing matching images to Viam. See the [filtered-camera guide](/data/filter-at-the-edge/#use-a-filtered-camera-with-ml) for more information. |
+
+For more Go module examples:
+
+- See the [Go SDK `examples` directory](https://github.com/viamrobotics/rdk/blob/main/examples/) for sample module code of varying complexity.
+- For additional examples use the [modular resources search](https://app.viam.com/registry) to search for examples of the model you are implementing, and click on the model's link to be able to browse its code.
+
+{{% /tab %}}
+{{% tab name="C++" %}}
+
+
+| Module | Repository | Description |
+| ------ | ---------- | ----------- |
+| [csi-cam](https://app.viam.com/module/viam/csi-cam) | [viamrobotics/csi-camera](https://github.com/viamrobotics/csi-camera/) | Extends the built-in [camera API](/reference/apis/components/camera/) to support the Intel CSI camera. |
+
+
+{{% /tab %}}
+{{% /tabs %}}
+
+Explore the full list of available modules in the [registry](https://app.viam.com/registry).
+{{< /expand >}}
+
+Follow the instructions below to define the capabilities provided by your model, for the language you are using to write your module code:
+
+{{% alert title="Note: Pin numbers" color="note" %}}
+
+If your module references {{< glossary_tooltip term_id="pin-number" text="pin numbers" >}}, you should use physical board pin numbers, _not_ GPIO (BCM) numbers, to maintain consistency across {{< glossary_tooltip term_id="resource" text="resources" >}} from different sources.
+
+{{% /alert %}}
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+First, inspect the built-in class provided by the resource API that you are extending.
+
+For example, if you wanted to add support for a new [base component](/operate/reference/components/base/) to Viam (the component that represents the central physical platform of your machine, to which all other components are connected), you would start by looking at the built-in `Base` component class, which is defined in the [Viam Python SDK](https://github.com/viamrobotics/viam-python-sdk) in the following file:
+
+
+| Resource Model File | Description |
+| ------------------- | ----------- |
+| [src/viam/components/base/base.py](https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/components/base/base.py) | Defines the built-in `Base` class, which includes several built-in methods such as `move_straight()`. |
+
+{{% alert title="Tip" color="tip" %}}
+You can view the other built-in component classes in similar fashion.
+For example, the `Camera` class is defined in [camera.py](https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/components/camera/camera.py) and the `Sensor` class is defined in [sensor.py](https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/components/sensor/sensor.py).
+The same applies to service APIs.
+For example, the `MLModel` class for the ML Model service is defined in [mlmodel.py](https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/services/mlmodel/mlmodel.py).
+{{% /alert %}}
+
+Take note of the methods defined as part of the class API, such as `move_straight()` for the `Base` class.
+Your new resource model must either:
+
+- implement all of the methods that the corresponding resource API provides, or
+- explicitly raise a `NotImplementedError()` in the body of functions you do not want to implement or put `pass`.
+
+Otherwise, your new class will not instantiate.
+
+Next, create a file that will define your new resource model.
+This file will inherit from the existing class for your resource type, implement each built-in method for that class (or raise a `NotImplementedError()` for it), and define any new functionality you want to include as part of your model.
+
+For example, the following file, `my_base.py`:
+
+- defines a new model `acme:my-custom-base-module:mybase` by implementing a new `MyBase` class, which inherits from the built-in class `Base`.
+- defines a new constructor `new_base()` and a new method `validate_config()`.
+- does not implement several built-in methods, including `get_properties()` and `set_velocity()`, but instead raises a `NotImplementedError` error in the body of those functions.
+ This prevents these methods from being used by new base components that use this modular resource, but meets the requirement that all built-in methods either be defined or raise a `NotImplementedError()` error, to ensure that the new `MyBase` class successfully instantiates.
+
+{{< expand "Click to view sample code for my_base.py" >}}
+
+```python {class="line-numbers linkable-line-numbers"}
+from typing import ClassVar, Mapping, Sequence, Any, Dict, Optional, cast
+
+from typing_extensions import Self
+
+from viam.components.base import Base
+from viam.components.motor import Motor
+from viam.module.types import Reconfigurable
+from viam.module.module import Module
+from viam.proto.app.robot import ComponentConfig
+from viam.proto.common import ResourceName, Vector3
+from viam.resource.base import ResourceBase
+from viam.resource.registry import Registry, ResourceCreatorRegistration
+from viam.resource.types import Model, ModelFamily
+from viam.utils import ValueTypes
+from viam.logging import getLogger
+
+LOGGER = getLogger(__name__)
+
+
+class MyBase(Base, Reconfigurable):
+ """
+ MyBase implements a base that only supports set_power
+ (basic forward/back/turn controls) is_moving (check if in motion), and stop
+ (stop all motion).
+
+ It inherits from the built-in resource API Base and conforms to the
+ ``Reconfigurable`` protocol, which signifies that this component can be
+ reconfigured. Additionally, it specifies a constructor function
+ ``MyBase.new_base`` which confirms to the
+ ``resource.types.ResourceCreator`` type required for all models.
+ """
+
+ # Here is where we define our new model's colon-delimited-triplet:
+ # acme:my-custom-base-module:mybase
+ # acme = namespace, my-custom-base-module = module-name,
+ # mybase = model name
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("acme", "my-custom-base-module"), "mybase")
+
+ def __init__(self, name: str, left: str, right: str):
+ super().__init__(name, left, right)
+
+ # Constructor
+ @classmethod
+ def new_base(cls,
+ config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
+ base = cls(config.name)
+ base.reconfigure(config, dependencies)
+ return base
+
+ # Validates JSON Configuration
+ @classmethod
+ def validate_config(cls, config: ComponentConfig) -> Sequence[str]:
+ left_name = config.attributes.fields["motorL"].string_value
+ if left_name == "":
+ raise Exception(
+ "A motorL attribute is required for a MyBase component.")
+ right_name = [config.attributes.fields["motorR"].string_value]
+ if right_name == "":
+ raise Exception(
+ "A motorR attribute is required for a MyBase component.")
+ return [left_name, right_name]
+
+ # Handles attribute reconfiguration
+ def reconfigure(self,
+ config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]):
+ left_name = config.attributes.fields["motorL"].string_value
+ right_name = config.attributes.fields["motorR"].string_value
+
+ left_motor = dependencies[Motor.get_resource_name(left_name)]
+ right_motor = dependencies[Motor.get_resource_name(right_name)]
+
+ self.left = cast(Motor, left_motor)
+ self.right = cast(Motor, right_motor)
+
+ """
+ Implement the methods the Viam RDK defines for the base API
+ (rdk:component:base)
+ """
+
+ # move_straight: unimplemented
+ async def move_straight(self,
+ distance: int,
+ velocity: float,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+ raise NotImplementedError
+
+ # spin: unimplemented
+ async def spin(self,
+ angle: float,
+ velocity: float,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+ raise NotImplementedError
+
+ # set_power: set the linear and angular velocity of the left and right
+ # motors on the base
+ async def set_power(self,
+ linear: Vector3,
+ angular: Vector3,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+
+ # stop the base if absolute value of linear and angular velocity is
+ # less than .01
+ if abs(linear.y) < 0.01 and abs(angular.z) < 0.01:
+ return self.stop(extra=extra, timeout=timeout)
+
+ # use linear and angular velocity to calculate percentage of max power
+ # to pass to SetPower for left & right motors
+ sum = abs(linear.y) + abs(angular.z)
+
+ self.left.set_power(power=((linear.y - angular.z) / sum),
+ extra=extra,
+ timeout=timeout)
+ self.right.set_power(power=((linear.y + angular.z) / sum),
+ extra=extra,
+ timeout=timeout)
+
+ # set_velocity: unimplemented
+ async def set_velocity(self,
+ linear: Vector3,
+ angular: Vector3,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+ raise NotImplementedError
+
+ # get_properties: unimplemented
+ async def get_properties(self,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+ raise NotImplementedError
+
+ # stop: stop the base from moving by stopping both motors
+ async def stop(self,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs):
+ self.left.stop(extra=extra, timeout=timeout)
+ self.right.stop(extra=extra, timeout=timeout)
+
+ # is_moving: check if either motor on the base is moving with motors'
+ # is_powered
+ async def is_moving(self,
+ *,
+ extra: Optional[Dict[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs) -> bool:
+ return self.left.is_powered(extra=extra, timeout=timeout)[0] or \
+ self.right.is_powered(extra=extra, timeout=timeout)[0]
+```
+
+{{< /expand >}}
+
+When implementing built-in methods from the Viam Python SDK in your model, be sure your implementation of those methods returns any values designated in the built-in function's return signature, typed correctly.
+For example, the `is_moving()` implementation in the example code above returns a `bool` value, which matches the return value of the built-in `is_moving()` function as defined in the Viam Python SDK in the file [`base.py`](https://github.com/viamrobotics/viam-python-sdk/blob/main/src/viam/components/base/base.py).
+
+For more information on the base component API methods used in this example, see the following resources:
+
+- [Python SDK documentation for the `Base` class](https://python.viam.dev/autoapi/viam/components/base/index.html)
+- [Base API methods](/reference/apis/components/base/)
+
+{{% /tab %}}
+{{% tab name="Go"%}}
+
+First, inspect the built-in package provided by the resource API that you are extending.
+
+For example, if you wanted to add support for a new [base component](/operate/reference/components/base/) to Viam (the component that represents the central physical platform of your machine, to which all other components are connected), you would start by looking at the built-in `base` component package, which is defined in the [Viam Go SDK](https://github.com/viamrobotics/rdk/) in the following file:
+
+
+| Resource Model File | Description |
+| ------------------- | ----------- |
+| [components/base/base.go](https://github.com/viamrobotics/rdk/blob/main/components/base/base.go) | Defines the built-in `base` package, which includes several built-in methods such as `MoveStraight()`. |
+
+{{% alert title="Tip" color="tip" %}}
+You can view the other built-in component packages in similar fashion.
+For example, the `camera` package is defined in [camera.go](https://github.com/viamrobotics/rdk/blob/main/components/camera/camera.go) and the `sensor` package is defined in [sensor.go](https://github.com/viamrobotics/rdk/blob/main/components/sensor/sensor.go).
+The same applies to service APIs.
+For example, the `mlmodel` package for the ML Model service is defined in [mlmodel.go](https://github.com/viamrobotics/rdk/blob/main/services/mlmodel/mlmodel.go).
+{{% /alert %}}
+
+Take note of the methods defined as part of the package API, such as `MoveStraight()` for the `base` package.
+Your new resource model must either:
+
+- implement all of the methods that the corresponding resource API provides, or
+- explicitly return an `errUnimplemented` error in the body of functions you do not want to implement.
+
+Otherwise, your new package will not instantiate.
+
+Next, create a file that will define your new resource model.
+This file will inherit from the existing package for your resource type, implement - or return an `errUnimplemented` error for - each built-in method for that package, and define any new functionality you want to include as part of your model.
+
+For example, the following file, `mybase.go`:
+
+- defines a new model `acme:my-custom-base-module:mybase` by implementing a new `mybase` package, which inherits from the built-in package `base`.
+- defines a new constructor `newBase()` and a new method `Validate()`.
+- does not implement several built-in methods, including `MoveStraight()` and `SetVelocity()`, but instead returns an `errUnimplemented` error in the body of those methods.
+ This prevents these methods from being used by new base components that use this modular resource, but meets the requirement that all built-in methods either be defined or return an `errUnimplemented` error, to ensure that the new `mybase` package successfully instantiates.
+
+{{< expand "Click to view sample code for mybase.go" >}}
+
+```go {class="line-numbers linkable-line-numbers"}
+// Package mybase implements a base that only supports SetPower (basic forward/back/turn controls), IsMoving (check if in motion), and Stop (stop all motion).
+// It extends the built-in resource API Base and implements methods to handle resource construction, attribute configuration, and reconfiguration.
+
+package mybase
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "github.com/golang/geo/r3"
+ "github.com/pkg/errors"
+ "go.uber.org/multierr"
+
+ "go.viam.com/rdk/components/base"
+ "go.viam.com/rdk/components/base/kinematicbase"
+ "go.viam.com/rdk/components/motor"
+ "go.viam.com/rdk/logging"
+ "go.viam.com/rdk/resource"
+ "go.viam.com/rdk/spatialmath"
+)
+
+// Here is where we define your new model's colon-delimited-triplet (acme:my-custom-base-module:mybase)
+// acme = namespace, my-custom-base-module = module-name, mybase = model name.
+var (
+ Model = resource.NewModel("acme", "my-custom-base-module", "mybase")
+ errUnimplemented = errors.New("unimplemented")
+)
+
+const (
+ myBaseWidthMm = 500.0 // Base has a wheel tread of 500 millimeters
+ myBaseTurningRadiusM = 0.3 // Base turns around a circle of radius .3 meters
+)
+
+func init() {
+ resource.RegisterComponent(base.API, Model, resource.Registration[base.Base, *Config]{
+ Constructor: newBase,
+ })
+}
+
+func newBase(ctx context.Context, deps resource.Dependencies, conf resource.Config, logger logging.Logger) (base.Base, error) {
+ b := &myBase{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ }
+ if err := b.Reconfigure(ctx, deps, conf); err != nil {
+ return nil, err
+ }
+ return b, nil
+}
+
+
+// Reconfigure reconfigures with new settings.
+func (b *myBase) Reconfigure(ctx context.Context, deps resource.Dependencies, conf resource.Config) error {
+ b.left = nil
+ b.right = nil
+
+ // This takes the generic resource.Config passed down from the parent and converts it to the
+ // model-specific (aka "native") Config structure defined, above making it easier to directly access attributes.
+ baseConfig, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return err
+ }
+
+ b.left, err = motor.FromDependencies(deps, baseConfig.LeftMotor)
+ if err != nil {
+ return errors.Wrapf(err, "unable to get motor %v for mybase", baseConfig.LeftMotor)
+ }
+
+ b.right, err = motor.FromDependencies(deps, baseConfig.RightMotor)
+ if err != nil {
+ return errors.Wrapf(err, "unable to get motor %v for mybase", baseConfig.RightMotor)
+ }
+
+ geometries, err := kinematicbase.CollisionGeometry(conf.Frame)
+ if err != nil {
+ b.logger.CWarnf(ctx, "base %v %s", b.Name(), err.Error())
+ }
+ b.geometries = geometries
+
+ // Stop motors when reconfiguring.
+ return multierr.Combine(b.left.Stop(context.Background(), nil), b.right.Stop(context.Background(), nil))
+}
+
+// DoCommand simply echos whatever was sent.
+func (b *myBase) DoCommand(ctx context.Context, cmd map[string]interface{}) (map[string]interface{}, error) {
+ return cmd, nil
+}
+
+// Config contains two component (motor) names.
+type Config struct {
+ LeftMotor string `json:"motorL"`
+ RightMotor string `json:"motorR"`
+}
+
+// Validate validates the config and returns implicit dependencies,
+// this Validate checks if the left and right motors exist for the module's base model.
+func (cfg *Config) Validate(path string) ([]string, error) {
+ // check if the attribute fields for the right and left motors are non-empty
+ // this makes them required for the model to successfully build
+ if cfg.LeftMotor == "" {
+ return nil, fmt.Errorf(`expected "motorL" attribute for mybase %q`, path)
+ }
+ if cfg.RightMotor == "" {
+ return nil, fmt.Errorf(`expected "motorR" attribute for mybase %q`, path)
+ }
+
+ // Return the left and right motor names so that `newBase` can access them as dependencies.
+ return []string{cfg.LeftMotor, cfg.RightMotor}, nil
+}
+
+type myBase struct {
+ resource.Named
+ left motor.Motor
+ right motor.Motor
+ logger logging.Logger
+ geometries []spatialmath.Geometry
+}
+
+// MoveStraight does nothing.
+func (b *myBase) MoveStraight(ctx context.Context, distanceMm int, mmPerSec float64, extra map[string]interface{}) error {
+ return errUnimplemented
+}
+
+// Spin does nothing.
+func (b *myBase) Spin(ctx context.Context, angleDeg, degsPerSec float64, extra map[string]interface{}) error {
+ return errUnimplemented
+}
+
+// SetVelocity does nothing.
+func (b *myBase) SetVelocity(ctx context.Context, linear, angular r3.Vector, extra map[string]interface{}) error {
+ return errUnimplemented
+}
+
+// SetPower computes relative power between the wheels and sets power for both motors.
+func (b *myBase) SetPower(ctx context.Context, linear, angular r3.Vector, extra map[string]interface{}) error {
+ b.logger.CDebugf(ctx, "SetPower Linear: %.2f Angular: %.2f", linear.Y, angular.Z)
+ if math.Abs(linear.Y) < 0.01 && math.Abs(angular.Z) < 0.01 {
+ return b.Stop(ctx, extra)
+ }
+ sum := math.Abs(linear.Y) + math.Abs(angular.Z)
+ err1 := b.left.SetPower(ctx, (linear.Y-angular.Z)/sum, extra)
+ err2 := b.right.SetPower(ctx, (linear.Y+angular.Z)/sum, extra)
+ return multierr.Combine(err1, err2)
+}
+
+// Stop halts motion.
+func (b *myBase) Stop(ctx context.Context, extra map[string]interface{}) error {
+ b.logger.CDebug(ctx, "Stop")
+ err1 := b.left.Stop(ctx, extra)
+ err2 := b.right.Stop(ctx, extra)
+ return multierr.Combine(err1, err2)
+}
+
+// IsMoving returns true if either motor is active.
+func (b *myBase) IsMoving(ctx context.Context) (bool, error) {
+ for _, m := range []motor.Motor{b.left, b.right} {
+ isMoving, _, err := m.IsPowered(ctx, nil)
+ if err != nil {
+ return false, err
+ }
+ if isMoving {
+ return true, err
+ }
+ }
+ return false, nil
+}
+
+// Properties returns details about the physics of the base.
+func (b *myBase) Properties(ctx context.Context, extra map[string]interface{}) (base.Properties, error) {
+ return base.Properties{
+ TurningRadiusMeters: myBaseTurningRadiusM,
+ WidthMeters: myBaseWidthMm * 0.001, // converting millimeters to meters
+ }, nil
+}
+
+// Geometries returns physical dimensions.
+func (b *myBase) Geometries(ctx context.Context, extra map[string]interface{}) ([]spatialmath.Geometry, error) {
+ return b.geometries, nil
+}
+
+// Close stops motion during shutdown.
+func (b *myBase) Close(ctx context.Context) error {
+ return b.Stop(ctx, nil)
+}
+```
+
+{{< /expand >}}
+
+{{< alert title="Note" color="note" >}}
+For an example featuring a sensor, see [MCP3004-8](https://github.com/mestcihazal/mcp3004-8-go).
+
+For additional examples use the [modular resources search](https://app.viam.com/registry) to search for examples of the model you are implementing, and click on the model's link to be able to browse its code.
+{{< /alert >}}
+
+When implementing built-in methods from the Viam Go SDK in your model, be sure your implementation of those methods returns any values designated in the built-in method's return signature, typed correctly.
+For example, the `SetPower()` implementation in the example code above returns a `multierr` value (as provided by the [`multierr` package](https://pkg.go.dev/go.uber.org/multierr)), which allows for transparently combining multiple Go `error` return values together.
+This matches the `error` return type of the built-in `SetPower()` method as defined in the Viam Go SDK in the file [`base.go`](https://github.com/viamrobotics/rdk/blob/main/components/base/base.go).
+
+For more information on the base component API methods used in this example, see the following resources:
+
+- [Go SDK documentation for the `base` package](https://pkg.go.dev/go.viam.com/rdk/components/base#pkg-functions)
+- [Base API methods](/reference/apis/components/base/)
+
+{{% /tab %}}
+{{% tab name="C++" %}}
+
+First, inspect the built-in class provided by the resource API that you are extending.
+In the C++ SDK, all built-in classes are abstract classes.
+
+For example, if you wanted to add support for a new [base component](/operate/reference/components/base/) to Viam (the component that represents the central physical platform of your machine, to which all other components are connected), you would start by looking at the built-in `Base` component class, which is defined in the [Viam C++ SDK](https://cpp.viam.dev/) in the following files:
+
+
+| Resource Model File | Description |
+| ------------------- | ----------- |
+| [components/base/base.hpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/base.hpp) | Defines the API of the built-in `Base` class, which includes the declaration of several purely virtual built-in functions such as `move_straight()`. |
+| [components/base/base.cpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/base.cpp) | Provides implementations of the non-purely virtual functionality defined in `base.hpp`. |
+
+{{% alert title="Tip" color="tip" %}}
+You can view the other built-in component classes in similar fashion.
+For example, the API of the built-in `Camera` class is defined in [camera.hpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/camera.hpp) and its non-purely virtual functions are declared in [camera.cpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/camera.cpp), while the API of the built-in `Sensor` class is defined in [sensor.hpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/sensor.hpp) and its non-purely virtual functions are declared in [sensor.cpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/sensor.cpp).
+The same applies to service APIs.
+For example, the API of the built-in `MLModelService` class for the ML Model service is defined in [mlmodel.hpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/services/mlmodel.hpp) and its non-purely virtual functions declared in [mlmodel.cpp](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/services/mlmodel.cpp).
+{{% /alert %}}
+
+Take note of the functions defined as part of the class API, such as `move_straight()` for the `Base` class.
+Your new resource model must either:
+
+- define all _pure virtual methods_ that the corresponding resource API provides, or
+- explicitly `throw` a `runtime_error` in the body of functions you do not want to implement.
+
+Otherwise, your new class will not instantiate.
+For example, if your model implements the `base` class, you would either need to implement the [`move_straight()` virtual method](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/base.hpp#L72), or `throw` a `runtime_error` in the body of that function.
+However, you would _not_ need to implement the [`resource_registration()`](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/base.hpp#L56) function, as it is not a virtual method.
+
+Next, create your header file (`.hpp`) and source file (`.cpp`), which together define your new resource model.
+The header file defines the API of your class, and includes the declaration of any purely virtual functions, while the source file includes implementations of the functionality of your class.
+
+For example, the files below define the new `MyBase` class and its constituent functions:
+
+- The `my_base.hpp` header file defines the API of the `MyBase` class, which inherits from the built-in `Base` class.
+ It defines a new method `validate()`, but does not implement several built-in functions, including `move_straight()` and `set_velocity()`, instead it raises a `runtime_error` in the body of those functions.
+ This prevents these functions from being used by new base components that use this modular resource, but meets the requirement that all built-in functions either be defined or `throw` a `runtime_error` error, to ensure that the new `MyBase` class successfully instantiates.
+- The `my_base.cpp` source file contains the function and object definitions used by the `MyBase` class.
+
+Note that the model triplet itself, `acme:my-custom-base-module:mybase` in this example, is defined in the entry point (main program) file `main.cpp`, which is described in the next section.
+
+{{< expand "Click to view sample code for the my_base.hpp header file" >}}
+
+```cpp {class="line-numbers linkable-line-numbers"}
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace viam::sdk;
+
+// `MyBase` inherits from the `Base` class defined in the Viam C++ SDK and
+// implements some of the relevant methods along with `reconfigure`. It also
+// specifies a static `validate` method that checks configuration validity.
+class MyBase : public Base {
+ public:
+ MyBase(Dependencies deps, ResourceConfig cfg) : Base(cfg.name()) {
+ this->reconfigure(deps, cfg);
+ };
+ void reconfigure(Dependencies deps, ResourceConfig cfg) override;
+ static std::vector validate(ResourceConfig cfg);
+
+ bool is_moving() override;
+ void stop(const AttributeMap& extra) override;
+ void set_power(const Vector3& linear,
+ const Vector3& angular,
+ const AttributeMap& extra) override;
+
+ AttributeMap do_command(const AttributeMap& command) override;
+ std::vector get_geometries(const AttributeMap& extra) override;
+ Base::properties get_properties(const AttributeMap& extra) override;
+
+ void move_straight(int64_t distance_mm, double mm_per_sec, const AttributeMap& extra) override {
+ throw std::runtime_error("move_straight unimplemented");
+ }
+ void spin(double angle_deg, double degs_per_sec, const AttributeMap& extra) override {
+ throw std::runtime_error("spin unimplemented");
+ }
+ void set_velocity(const Vector3& linear,
+ const Vector3& angular,
+ const AttributeMap& extra) override {
+ throw std::runtime_error("set_velocity unimplemented");
+ }
+
+ private:
+ std::shared_ptr left_;
+ std::shared_ptr right_;
+};
+```
+
+{{< /expand >}}
+
+{{< expand "Click to view sample code for the my_base.cpp source file" >}}
+
+```cpp {class="line-numbers linkable-line-numbers"}
+#include "my_base.hpp"
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+
+using namespace viam::sdk;
+
+std::string find_motor(ResourceConfig cfg, std::string motor_name) {
+ auto base_name = cfg.name();
+ auto motor = cfg.attributes()->find(motor_name);
+ if (motor == cfg.attributes()->end()) {
+ std::ostringstream buffer;
+ buffer << base_name << ": Required parameter `" << motor_name
+ << "` not found in configuration";
+ throw std::invalid_argument(buffer.str());
+ }
+ const auto* const motor_string = motor->second->get();
+ if (!motor_string || motor_string->empty()) {
+ std::ostringstream buffer;
+ buffer << base_name << ": Required non-empty string parameter `" << motor_name
+ << "` is either not a string "
+ "or is an empty string";
+ throw std::invalid_argument(buffer.str());
+ }
+ return *motor_string;
+}
+
+void MyBase::reconfigure(Dependencies deps, ResourceConfig cfg) {
+ // Downcast `left` and `right` dependencies to motors.
+ auto left = find_motor(cfg, "left");
+ auto right = find_motor(cfg, "right");
+ for (const auto& kv : deps) {
+ if (kv.first.short_name() == left) {
+ left_ = std::dynamic_pointer_cast(kv.second);
+ }
+ if (kv.first.short_name() == right) {
+ right_ = std::dynamic_pointer_cast(kv.second);
+ }
+ }
+}
+
+std::vector MyBase::validate(ResourceConfig cfg) {
+ // Custom validation can be done by specifying a validate function at the
+ // time of resource registration (see main.cpp) like this one.
+ // Validate functions can `throw` exceptions that will be returned to the
+ // parent through gRPC. Validate functions can also return a vector of
+ // strings representing the implicit dependencies of the resource.
+ //
+ // Here, we return the names of the "left" and "right" motors as found in
+ // the attributes as implicit dependencies of the base.
+ return {find_motor(cfg, "left"), find_motor(cfg, "right")};
+}
+
+bool MyBase::is_moving() {
+ return left_->is_moving() || right_->is_moving();
+}
+
+void MyBase::stop(const AttributeMap& extra) {
+ std::string err_message;
+ bool throw_err = false;
+
+ // make sure we try to stop both motors, even if the first fails.
+ try {
+ left_->stop(extra);
+ } catch (const std::exception& err) {
+ throw_err = true;
+ err_message = err.what();
+ }
+
+ try {
+ right_->stop(extra);
+ } catch (const std::exception& err) {
+ throw_err = true;
+ err_message = err.what();
+ }
+
+ // if we received an err from either motor, throw it.
+ if (throw_err) {
+ throw std::runtime_error(err_message);
+ }
+}
+
+void MyBase::set_power(const Vector3& linear, const Vector3& angular, const AttributeMap& extra) {
+ // Stop the base if absolute value of linear and angular velocity is less
+ // than 0.01.
+ if (abs(linear.y()) < 0.01 && abs(angular.z()) < 0.01) {
+ stop(extra); // ignore returned status code from stop
+ return;
+ }
+
+ // Use linear and angular velocity to calculate percentage of max power to
+ // pass to set_power for left & right motors
+ auto sum = abs(linear.y()) + abs(angular.z());
+ left_->set_power(((linear.y() - angular.z()) / sum), extra);
+ right_->set_power(((linear.y() + angular.z()) / sum), extra);
+}
+
+AttributeMap MyBase::do_command(const AttributeMap& command) {
+ std::cout << "Received DoCommand request for MyBase " << Resource::name() << std::endl;
+ return command;
+}
+
+std::vector MyBase::get_geometries(const AttributeMap& extra) {
+ auto left_geometries = left_->get_geometries(extra);
+ auto right_geometries = right_->get_geometries(extra);
+ std::vector geometries(left_geometries);
+ geometries.insert(geometries.end(), right_geometries.begin(), right_geometries.end());
+ return geometries;
+}
+
+Base::properties MyBase::get_properties(const AttributeMap& extra) {
+ // Return fake properties.
+ return {2, 4, 8};
+}
+```
+
+{{< /expand >}}
+
+When implementing built-in functions from the Viam C++ SDK in your model, be sure your implementation of those functions returns any values designated in the built-in function's return signature, typed correctly.
+For example, the `set_power()` implementation in the example code above returns three values of type `Vector3`, `Vector3`, `AttributeMap`, which matches the return values of the built-in `set_power()` function as defined in the Viam C++ SDK in the file [`base.hpp`](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/src/viam/sdk/components/base.hpp).
+
+For more information on the base component API methods used in these examples, see the following resources:
+
+- [C++ SDK documentation for the `Base` class](https://cpp.viam.dev/classviam_1_1sdk_1_1Base.html)
+- [Base API methods](/reference/apis/components/base/)
+
+For more C++ module examples of varying complexity,see the [C++ SDK `examples` directory](https://github.com/viamrobotics/viam-cpp-sdk/tree/main/src/viam/examples/modules/).
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Write an entry point (main program) file
+
+A main entry point file starts the module, and adds the resource model.
+
+Follow the instructions below for the language you are using to write your module code:
+
+{{< tabs name="Sample SDK Main Program Code">}}
+{{% tab name="Python"%}}
+
+Create a main.py file to serve as the module's entry point file, which:
+
+- imports the custom model
+- defines a `main()` function that registers the model with the Python SDK
+- creates and starts the module
+
+For example, the following `main.py` file serves as the entry point file for the `MyBase` custom model.
+It imports the `MyBase` model from the `my_base.py` file that provides it, and defines a `main()` function that registers it.
+
+{{< expand "Click to view sample code for main.py" >}}
+
+```python {class="line-numbers linkable-line-numbers"}
+import asyncio
+
+from viam.components.base import Base
+from viam.module.module import Module
+from viam.resource.registry import Registry, ResourceCreatorRegistration
+from my_base import MyBase
+
+
+async def main():
+ """
+ This function creates and starts a new module, after adding all desired
+ resource models. Resource creators must be registered to the resource
+ registry before the module adds the resource model.
+ """
+ Registry.register_resource_creator(
+ Base.API,
+ MyBase.MODEL,
+ ResourceCreatorRegistration(MyBase.new_base, MyBase.validate_config))
+ module = Module.from_args()
+
+ module.add_model_from_registry(Base.API, MyBase.MODEL)
+ await module.start()
+
+if __name__ == "__main__":
+ asyncio.run(main())
+```
+
+{{< /expand >}}
+
+{{% /tab %}}
+{{% tab name="Go"%}}
+
+Create a main.go file to serve as the module's entry point file, which:
+
+- imports the custom model
+- defines a `main()` function that registers the model with the Viam Go SDK
+- creates and starts the module
+
+For example, the following `main.go` file serves as the entry point file for the `mybase` custom model.
+It imports the `mybase` model from the `my_base.go` file that provides it, and defines a `main()` function that registers it.
+
+{{< expand "Click to view sample code for main.go" >}}
+
+```go {class="line-numbers linkable-line-numbers"}
+// Package main is a module which serves the mybase custom model.
+package main
+
+import (
+ "context"
+
+ "go.viam.com/rdk/components/base"
+ "go.viam.com/rdk/logging"
+ "go.viam.com/rdk/module"
+ "go.viam.com/rdk/utils"
+
+ // Import your local package "mybase"
+ // NOTE: Update this path if your custom resource is in a different location,
+ // or has a different name:
+ "go.viam.com/rdk/examples/customresources/models/mybase"
+)
+
+func main() {
+ // NewLoggerFromArgs will create a logging.Logger at "DebugLevel" if
+ // "--log-level=debug" is an argument in os.Args and at "InfoLevel" otherwise.
+ utils.ContextualMain(mainWithArgs, module.NewLoggerFromArgs("mybase"))
+}
+
+func mainWithArgs(ctx context.Context, args []string, logger logging.Logger) (err error) {
+ myMod, err := module.NewModuleFromArgs(ctx, logger)
+ if err != nil {
+ return err
+ }
+
+ // Models and APIs add helpers to the registry during their init().
+ // They can then be added to the module here.
+ err = myMod.AddModelFromRegistry(ctx, base.API, mybase.Model)
+ if err != nil {
+ return err
+ }
+
+ err = myMod.Start(ctx)
+ defer myMod.Close(ctx)
+ if err != nil {
+ return err
+ }
+ <-ctx.Done()
+ return nil
+}
+```
+
+{{< /expand >}}
+
+{{% /tab %}}
+{{% tab name="C++" %}}
+
+Create a main.cpp file to serve as the module's entry point file, which:
+
+- imports the custom model implementation and definitions
+- includes a `main()` function that registers the model with the Viam C++ SDK
+- creates and starts the module
+
+For example, the following `main.cpp` file serves as the entry point file for the `mybase` custom model.
+It imports the `mybase` model implementation from the `my_base.hpp` file that provides it, declares the model triplet `acme:my-custom-base-module:mybase`, and defines a `main()` function that registers it.
+
+{{< expand "Click to view sample code for main.cpp" >}}
+
+```cpp {class="line-numbers linkable-line-numbers"}
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "my_base.hpp"
+
+using namespace viam::sdk;
+
+int main(int argc, char** argv) {
+ API base_api = Base::static_api();
+ Model mybase_model("acme", "my-custom-base-module", "mybase");
+
+ std::shared_ptr mybase_mr = std::make_shared(
+ base_api,
+ mybase_model,
+ [](Dependencies deps, ResourceConfig cfg) { return std::make_unique(deps, cfg); },
+ MyBase::validate);
+
+ std::vector> mrs = {mybase_mr};
+ auto my_mod = std::make_shared(argc, argv, mrs);
+ my_mod->serve();
+
+ return EXIT_SUCCESS;
+};
+```
+
+{{< /expand >}}
+
+{{% /tab %}}
+{{< /tabs >}}
+
+#### (Optional) Configure logging
+
+If desired, you can configure your module to output log messages.
+Log messages appear on the [**LOGS** tab](/monitor/troubleshoot/#check-logs) for your machine in an easily-parsable and searchable manner.
+
+Log messages generated when your machine is offline are queued, and sent together when your machine connects to the internet once more.
+
+Add the following code to your module code to enable logging to Viam, depending on the language you using to code your module. You can log in this fashion from the model definition file or files, the entry point (main program) file, or both, depending on your logging needs:
+
+{{% alert title="Tip" color="tip" %}}
+The example code shown above under [Write your new resource model definition](#write-your-new-resource-model-definition) includes the requisite logging code already.
+{{% /alert %}}
+
+{{< tabs name="Configure logging">}}
+{{% tab name="Python"%}}
+
+To enable your Python module to write resource-level log messages to Viam, add the following lines to your code:
+
+```python {class="line-numbers linkable-line-numbers"}
+# Within some method, log information:
+self.logger.debug("debug info")
+self.logger.info("info")
+self.logger.warn("warning info")
+self.logger.error("error info")
+self.logger.exception("error info", exc_info=True)
+self.logger.critical("critical info")
+```
+
+Resource-level logs are recommended instead of global logs for modular resources, because they make it easier to determine which component or service an error is coming from.
+Resource-level error logs appear in the **Error logs** section of each resource's configuration card in the app.
+
+{{% alert title="Note" color="note" %}}
+In order to see resource-level debug logs when using your modular resource, you'll either need to run `viam-server` with the `-debug` option or [configure your machine or individual resource to display debug logs](/operate/reference/viam-server/#logging).
+{{% /alert %}}
+
+{{< expand "Click to see global logging" >}}
+
+If you need to publish to the global machine-level logs instead of using the recommended resource-level logging, you can follow this example:
+
+```python {class="line-numbers linkable-line-numbers" data-line="2,5"}
+# In your import block, import the logging package:
+from viam.logging import getLogger
+
+# Before your first class or function, define the LOGGER variable:
+LOGGER = getLogger(__name__)
+
+# in some method, log information
+LOGGER.debug("debug info")
+LOGGER.info("info info")
+LOGGER.warn("warn info")
+LOGGER.error("error info")
+LOGGER.exception("error info", exc_info=True)
+LOGGER.critical("critical info")
+```
+
+{{< /expand >}}
+
+{{% /tab %}}
+{{% tab name="Go"%}}
+
+To enable your Go module to write log messages to Viam, add the following lines to your code:
+
+```go {class="line-numbers linkable-line-numbers"}
+// In your import() block, import the logging package:
+import(
+ ...
+ "go.viam.com/rdk/logging"
+)
+// Alter your component to hold a logger
+type component struct {
+ ...
+ logger logging.Logger
+}
+// Then, alter your component's constructor to save the logger:
+func init() {
+ registration := resource.Registration[resource.Resource, *Config]{
+ Constructor: func(ctx context.Context, deps resource.Dependencies, conf resource.Config, logger logging.Logger) (resource.Resource, error) {
+ ...
+ return &component {
+ ...
+ logger: logger
+ }, nil
+ },
+ }
+ resource.RegisterComponent(...)
+}
+// Finally, when you need to log, use the functions on your component's logger:
+fn (c *component) someFunction(ctx context.Context, a int) {
+ // Log with severity info:
+ c.logger.CInfof(ctx, "performing some function with a=%v", a)
+ // Log with severity debug (using value wrapping):
+ c.logger.CDebugw(ctx, "performing some function", "a" ,a)
+ // Log with severity warn:
+ c.logger.CWarnw(ctx, "encountered warning for component", "name", c.Name())
+ // Log with severity error without a parameter:
+ c.logger.CError(ctx, "encountered an error")
+}
+```
+
+{{% /tab %}}
+{{% tab name="C++" %}}
+
+`viam-server` automatically gathers all output sent to the standard output (`STDOUT`) in your C++ code and forwards it to Viam when a network connection is available.
+
+We recommend that you use a C++ logging library to assist with log message format and creation, such as the [Boost trivial logger](https://www.boost.org/doc/libs/1_84_0/libs/log/doc/html/log/tutorial/trivial_filtering.html):
+
+```cpp {class="line-numbers linkable-line-numbers"}
+#include
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### Compile or package your module
+
+The final step to creating a new module is to create an executable file that `viam-server` can use to run your module on demand.
+
+This executable file:
+
+- runs your module when executed
+- takes a local UNIX socket as a command line argument
+- exits cleanly when sent a termination signal
+
+Depending on the language you are using to code your module, you may have options for how you create your executable file:
+{{% tabs %}}
+{{% tab name="Python: pyinstaller (recommended)" %}}
+
+The recommended approach for Python is to use [`PyInstaller`](https://pypi.org/project/pyinstaller/) to compile your module into a packaged executable: a standalone file containing your program, the Python interpreter, and all of its dependencies.
+When packaged in this fashion, you can run the resulting executable on your desired target platform or platforms without needing to install additional software or manage dependencies manually.
+
+To create a packaged executable:
+
+1. First, [create a Python virtual environment](/reference/sdks/python/python-venv/) in your module's directory to ensure your module has access to any required libraries.
+ Be sure you are within your Python virtual environment for the rest of these steps: your terminal prompt should include the name of your virtual environment in parentheses.
+
+1. Create a `requirements.txt` file containing a list of all the dependencies your module requires.
+ For example, a `requirements.txt` file with the following contents ensures that the Viam Python SDK (`viam-sdk`), PyInstaller (`pyinstaller`), and the Google API Python client (`google-api-python-client`) are installed:
+
+ ```sh { class="command-line" data-prompt="$"}
+ viam-sdk
+ pyinstaller
+ google-api-python-client
+ ```
+
+ Add additional dependencies for your module as needed.
+ See the [pip `requirements.txt` file documentation](https://pip.pypa.io/en/stable/reference/requirements-file-format/) for more information.
+
+1. Install the dependencies listed in your `requirements.txt` file within your Python virtual environment using the following command:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m pip install -r requirements.txt -U
+ ```
+
+1. Then compile your module, adding the Google API Python client as a hidden import:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m PyInstaller --onefile --hidden-import="googleapiclient" src/main.py
+ ```
+
+ If you need to include any additional data files to support your module, specify them using the `--add-data` flag:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m PyInstaller --onefile --hidden-import="googleapiclient" --add-data src/arm/my_arm_kinematics.json:src/arm/ src/main.py
+ ```
+
+ By default, the output directory for the packaged executable is dist , and the name of the executable is derived from the name of the input script (in this case, main).
+
+We recommend you use PyInstaller with the [`build-action` GitHub action](https://github.com/viamrobotics/build-action) which provides a simple cross-platform build setup for multiple platforms: x86 and Arm Linux distributions, and macOS.
+Follow the instructions to [Update an existing module using a GitHub action](/build-modules/manage-modules/#update-automatically-from-a-github-repo-with-cloud-build) to add the build configuration to your machine.
+
+With this approach, you can make a build script like the following to
+build your module, and configure the resulting executable (dist/main ) as your module `"entrypoint"`:
+
+```sh { class="command-line"}
+#!/bin/bash
+set -e
+
+sudo apt-get install -y python3-venv
+python3 -m venv .venv
+. .venv/bin/activate
+pip3 install -r requirements.txt
+python3 -m PyInstaller --onefile --hidden-import="googleapiclient" src/main.py
+tar -czvf dist/archive.tar.gz dist/main
+```
+
+This script automates the process of setting up a Python virtual environment on a Linux arm64 machine, installing dependencies, packaging the Python module into a standalone executable using PyInstaller, and then compressing the resulting executable into a tarball.
+For more examples of build scripts see [Update an existing module using a GitHub action](/build-modules/manage-modules/#update-automatically-from-a-github-repo-with-cloud-build).
+
+{{% alert title="Note" color="note" %}}
+
+PyInstaller does not support relative imports in entrypoints (imports starting with `.`).
+If you get `"ImportError: attempted relative import with no known parent package"`, set up a stub entrypoint as described on [GitHub](https://github.com/pyinstaller/pyinstaller/issues/2560).
+
+In addition, PyInstaller does not support cross-compiling: you must compile your module on the target architecture you wish to support.
+For example, you cannot run a module on a Linux `arm64` system if you compiled it using PyInstaller on a Linux `amd64` system.
+Viam makes this easy to manage by providing a build system for modules.
+Follow [these instructions](/cli/#using-the-build-subcommand) to automatically build for each system your module can support using Viam's [CLI](/cli/).
+
+{{% /alert %}}
+
+{{% /tab %}}
+{{% tab name="Python: venv" %}}
+
+Create a `run.sh` shell script that creates a new Python virtual environment, ensures that the package dependencies your module requires are installed, and runs your module.
+This is the recommended approach for modules written in Python:
+
+1. Create a `requirements.txt` file containing a list of all the dependencies your module requires.
+ For example, a `requirements.txt` file with the following contents ensures that the Viam Python SDK (`viam-sdk`) is installed:
+
+ ```sh { class="command-line" data-prompt="$"}
+ viam-sdk
+ ```
+
+ Add additional dependencies for your module as needed.
+ See the [pip `requirements.txt` file documentation](https://pip.pypa.io/en/stable/reference/requirements-file-format/) for more information.
+
+1. Add a shell script that creates a new virtual environment, installs the dependencies listed in `requirements.txt`, and runs the module entry point file `main.py`:
+
+ ```sh { class="command-line" data-prompt="$"}
+ #!/bin/sh
+ cd `dirname $0`
+
+ # Create a virtual environment to run our code
+ VENV_NAME="venv"
+ PYTHON="$VENV_NAME/bin/python"
+
+ python3 -m venv $VENV_NAME
+ $PYTHON -m pip install -r requirements.txt -U # remove -U if viam-sdk should not be upgraded whenever possible
+
+ # Be sure to use `exec` so that termination signals reach the python process,
+ # or handle forwarding termination signals manually
+ exec $PYTHON /main.py $@
+ ```
+
+1. Make your shell script executable by running the following command in your terminal:
+
+ ```sh { class="command-line" data-prompt="$"}
+ sudo chmod +x /run.sh
+ ```
+
+Using a virtual environment together with a `requirements.txt` file and a `run.sh` file that references it ensures that your module has access to any packages it requires during runtime.
+If you intend to share your module with other users, or to deploy it to a fleet of machines, this approach handles dependency resolution for each deployment automatically, meaning that there is no need to explicitly determine and install the Python packages your module requires to run on each machine that installs your module.
+See [prepare a Python virtual environment](/reference/sdks/python/python-venv/) for more information.
+
+{{% /tab %}}
+{{% tab name="Python: nuitka" %}}
+
+Use the [`nuitka` Python compiler](https://pypi.org/project/Nuitka/) to compile your module into a single executable file:
+
+1. In order to use Nuitka, you must install a [supported C compiler](https://github.com/Nuitka/Nuitka#c-compiler) on your machine.
+
+1. Then, [create a Python virtual environment](/reference/sdks/python/python-venv/) in your module's directory to ensure your module has access to any required libraries.
+ Be sure you are within your Python virtual environment for the rest of these steps: your terminal prompt should include the name of your virtual environment in parentheses.
+
+1. Create a `requirements.txt` file containing a list of all the dependencies your module requires.
+ For example, a `requirements.txt` file with the following contents ensures that the Viam Python SDK (`viam-sdk`) and Nuitka (`nuitka`) are installed:
+
+ ```sh { class="command-line" data-prompt="$"}
+ viam-sdk
+ nuitka
+ ```
+
+ Add additional dependencies for your module as needed.
+ See the [pip `requirements.txt` file documentation](https://pip.pypa.io/en/stable/reference/requirements-file-format/) for more information.
+
+1. Install the dependencies listed in your `requirements.txt` file within your Python virtual environment using the following command:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m pip install -r requirements.txt -U
+ ```
+
+1. Then, compile your module using Nuitka with the following command:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m nuitka --onefile src/main.py
+ ```
+
+ If you need to include any additional data files to support your module, specify them using the `--include-data-files` flag:
+
+ ```sh { class="command-line" data-prompt="$"}
+ python -m nuitka --onefile --include-data-files=src/arm/my_arm_kinematics.json src/main.py
+ ```
+
+Compiling your Python module in this fashion ensures that your module has access to any packages it requires during runtime.
+If you intend to share your module with other users, or to deploy it to a fleet of machines, this approach "bundles" your module code together with its required dependencies, making your module highly-portable across like architectures.
+
+However, used in this manner, Nuitka does not support relative imports (imports starting with `.`).
+In addition, Nuitka does not support cross-compiling: you can only compile your module on the target architecture you wish to support if using the Nutika approach.
+If you want to cross-compile your module, consider using a different local compilation method, or the [`module build start` command](/cli/#using-the-build-subcommand) to build your module on a cloud build host, which supports building for multiple platforms.
+For example, you cannot run a module on a Linux `arm64` system if you compiled it using Nuitka on a Linux `amd64` system.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Use Go to compile your module into a single executable:
+
+- Navigate to your module directory in your terminal.
+- Run `go build` to compile your entry point (main program) file main.go and all other .go files in the directory, building your module and all dependencies into a single executable file.
+- Run `ls` in your module directory to find the executable, which should have the same name as the module directory.
+
+Compiling your Go module also generates the `go.mod` and `go.sum` files that define dependency resolution in Go.
+
+See the [Go compilation documentation](https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies) for more information.
+
+{{% /tab %}}
+{{% tab name="C++" %}}
+
+Create a CMakeLists.txt file to define how to compile your module and a run.sh file to wrap your executable, and then use C++ to compile your source files into a single executable:
+
+1. Create a CMakeLists.txt file in your module directory to instruct the compiler how to compile your module.
+ For example, the following basic configuration downloads the C++ SDK and handles compile-time linking for a module named `my-base`:
+
+ ```sh {class="line-numbers linkable-line-numbers"}
+ cmake_minimum_required(VERSION 3.7 FATAL_ERROR)
+
+ project(my-base LANGUAGES CXX)
+
+ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
+
+ include(FetchContent)
+ FetchContent_Declare(
+ viam-cpp-sdk
+ GIT_REPOSITORY https://github.com/viamrobotics/viam-cpp-sdk.git
+ GIT_TAG main
+ # SOURCE_DIR ${CMAKE_SOURCE_DIR}/../viam-cpp-sdk
+ CMAKE_ARGS -DVIAMCPPSDK_USE_DYNAMIC_PROTOS=ON
+ FIND_PACKAGE_ARGS
+ )
+ FetchContent_MakeAvailable(viam-cpp-sdk)
+
+ FILE(GLOB sources *.cpp)
+ add_executable(my-base ${sources})
+ target_link_libraries(my-base PRIVATE viam-cpp-sdk::viamsdk)
+ ```
+
+1. Create a run.sh file in your module directory to wrap the executable and perform basic sanity checks at runtime.
+
+ The following example shows a simple configuration that runs a module named `my-base`:
+
+ ```sh {class="line-numbers linkable-line-numbers"}
+ #!/usr/bin/env bash
+
+ # bash safe mode
+ set -euo pipefail
+
+ cd $(dirname $0)
+ exec ./my-base $@
+ ```
+
+1. Use C++ to compile and obtain a single executable for your module:
+
+ 1. Create a new build directory within your module directory:
+
+ ```sh { class="command-line"}
+ mkdir build
+ cd build
+ ```
+
+ 1. Build and compile your module:
+
+ ```sh { class="command-line"}
+ cmake .. -G Ninja
+ ninja all
+ ninja install
+ ```
+
+ 1. Run `ls` in your module's build directory to find the compiled executable, which should have the same name as the module directory (`my-base` in these examples):
+
+For more information on building a module in C++, see the [C++ SDK Build Documentation](https://github.com/viamrobotics/viam-cpp-sdk/blob/main/BUILDING.md).
+
+{{% /tab %}}
+{{% /tabs %}}
+
+### (Optional) Create a README
+
+To provide usage instructions for any modular resources in your module, you should create a README.md file following this template:
+
+{{< expand "Click to view template" >}}
+
+Strings of the form `` indicate placeholders that you need to replace with your values.
+
+{{< tabs >}}
+{{% tab name="Template" %}}
+
+````md
+# [`` module]()
+
+This [module](/hardware/configure-hardware/) implements the [`` API] in an model.
+With this model, you can...
+
+## Requirements
+
+_Add instructions here for any requirements._
+
+```bash
+
+```
+
+## Configure your
+
+Navigate to your machine's **CONFIGURE** tab.
+[Add to your machine](/hardware/configure-hardware/).
+
+On the new component panel, copy and paste the following attribute template into your 's attributes field:
+
+```json
+{
+
+}
+```
+
+### Attributes
+
+The following attributes are available for `` s:
+
+| Name | Type | Required? | Description |
+| ------- | ------ | ------------ | ----------- |
+| `todo1` | string | **Required** | TODO |
+| `todo2` | string | Optional | TODO |
+
+### Example configuration
+
+```json
+{
+
+}
+```
+
+### Next steps
+
+_Add any additional information you want readers to know and direct them towards what to do next with this module._
+_For example:_
+
+- To test your...
+- To write code against your...
+
+## Troubleshooting
+
+_Add troubleshooting notes here._
+````
+
+{{% /tab %}}
+{{% tab name="Example" %}}
+
+````md
+# [`agilex-limo` module](https://app.viam.com/module/viam/agilex-limo)
+
+This module implements the [`rdk:component:base` API](https://docs.viam.com/reference/apis/components/base/) in an `agilex` model for the [AgileX LIMO](https://global.agilex.ai/products/limo-pro) base to be used with `viam-server`.
+This driver supports differential, ackermann, and omni directional steering modes over the serial port.
+
+## Configure your `agilex-limo` base
+
+> [!NOTE]
+> Before configuring your base, you must add a machine on [Viam](https://app.viam.com).
+
+Navigate to the **CONFIGURE** tab of your machine's page.
+[Add `base` / `agilex-limo` to your machine](/hardware/configure-hardware/).
+
+On the new component panel, copy and paste the following attribute template into your base's attributes field:
+
+```json
+{
+ "drive_mode": "",
+ "serial_path": ""
+}
+```
+
+> [!NOTE]
+> For more information, see [Configure hardware on your machine](/hardware/configure-hardware/).
+
+### Attributes
+
+The following attributes are available for `viam:base:agilex-limo` bases:
+
+
+| Name | Type | Required? | Description |
+| ------------- | ------ | ------------ | ----------- |
+| `drive_mode` | string | **Required** | LIMO [steering mode](https://docs.trossenrobotics.com/agilex_limo_docs/operation/steering_modes.html#switching-steering-modes). Options: `differential`, `ackermann`, `omni` (mecanum). |
+| `serial_path` | string | Optional | The full filesystem path to the serial device, starting with /dev/ . With your serial device connected, you can run `sudo dmesg \| grep tty` to show relevant device connection log messages, and then match the returned device name, such as `ttyTHS1`, to its device file, such as /dev/ttyTHS1 . If you omit this attribute, Viam will attempt to automatically detect the path. Default: `/dev/ttyTHS1` |
+
+### Example configurations:
+
+```json
+{
+ "drive_mode": "differential"
+}
+```
+
+```json
+{
+ "drive_mode": "omni",
+ "serial_path": "/dev/ttyTHS1"
+}
+```
+
+## Next steps
+
+- To test your base, go to the [**CONTROL** tab](/monitor/teleoperate/).
+- To write code against your base, use one of the [available SDKs](https://docs.viam.com/reference/sdks/).
+- To view examples using a base component, explore [these tutorials](https://docs.viam.com/tutorials/).
+
+## Local development
+
+This module is written in Go.
+
+To build: `make limobase`
+To test: `make test`
+````
+
+{{% /tab %}}
+{{< /tabs >}}
+
+{{< /expand >}}
+
+## Test your module locally
+
+{{% alert title="Tip" color="tip" %}}
+
+If you would like to test your module locally against a target platform other than your development machine before uploading it, you can for example sync your code to an SBC with architecture that matches your target platform, and test your module there to verify that any code changes you have made work as expected on your target platform.
+
+{{% /alert %}}
+
+To use a local module on your machine, first make sure any physical hardware implemented in your module is connected to your machine's computer.
+Add the module to your machine's config, then add the component or service it implements:
+
+1. Navigate to the **CONFIGURE** tab of your machine's page.
+
+1. Click the **+** icon next to your machine part in the left-hand menu, select **Advanced**, then **Local module**.
+
+1. Enter a **Name** for this instance of your module.
+
+1. Enter the module's **Executable path**.
+ This path must be the absolute path on your machine's filesystem to either:
+
+ - the module's executable file, such as `run.sh` or a compiled binary.
+ - a [packaged tarball](https://www.cs.swarthmore.edu/~newhall/unixhelp/howto_tar.html) of your module, ending in `.tar.gz` or `.tgz`.
+ If you are providing a tarball file in this field, be sure that your packaged tarball contains your module's [`meta.json` file](/build-modules/module-reference/) within it.
+
+1. Then, click the **Create** button, and click **Save** in the upper right corner to save your config.
+
+1. Still on your machine's **CONFIGURE** tab, click the **+** icon next to your machine part in the left-hand menu.
+
+1. Select **Advanced**, then **Local module**, then **Local component** or **Local service**.
+
+1. Select or enter the {{< glossary_tooltip term_id="model-namespace-triplet" text="model namespace triplet">}} of your modular resource's {{< glossary_tooltip term_id="model" text="model" >}}.
+
+ {{}}
+
+1. Select the type of modular resource provided by your module, such as a [camera](/operate/reference/components/camera/), from the dropdown menu.
+
+1. Enter a name for this instance of your modular resource.
+ This name must be different from the module name.
+
+1. Click **Create** to create the modular resource provided by the local module.
+
+Once you've added your local module using steps 1-5, you can repeat steps 6-11 to add as many additional instances of your modular resource as you need.
+
+## Upload your module to the modular resource registry
+
+Once you are satisfied with the state of your module, you can upload your module to the Viam Registry to:
+
+- share your module with other Viam users
+- deploy your module to a fleet of machines from a central interface
+
+See [Update and manage modules you created](/build-modules/manage-modules/) for instructions.
+
+## Deploy your module to more machines
+
+You have now created a module, and are ready to deploy it to a fleet of machines.
+There are two ways to deploy a module:
+
+- Through the Viam Registry: Once you have uploaded your new module to the Viam Registry, [add the module to one or more machines](/hardware/configure-hardware/).
+ You can also choose to configure [automated uploads for new module versions](/build-modules/manage-modules/#update-automatically-from-a-github-repo-with-cloud-build) through a continuous integration (CI) workflow, using a GitHub Action if desired, greatly simplifying how you push changes to your module to the registry as you make them.
+- As a local module (without uploading it to Viam), as you did in the [Test your module locally step above](#test-your-module-locally).
+ This is a great way to test, but if you'd like to use the module on more machines it's easiest to add it to the registry either publicly or privately.
+
+Often, developers first test their new module by deploying it as a local module to a test machine.
+With a local installation, you can test your module in a controlled environment to confirm that it functions as expected, and make changes to your module as needed.
+
+## Next steps
+
+For instructions on how to update or delete modules you've created, see the following how-to guide:
+
+{{< cards >}}
+{{% card link="/build-modules/manage-modules/" %}}
+{{< /cards >}}
+
+To read more about module development at Viam, check out these tutorials that create modules:
+
+{{< cards >}}
+{{% card link="/tutorials/custom/custom-base-dog/" %}}
+{{% card link="/tutorials/configure/pet-photographer/" %}}
+{{< /cards >}}
diff --git a/docs/build-modules/write-a-driver-module.md b/docs/build-modules/write-a-driver-module.md
new file mode 100644
index 0000000000..16d2cc348d
--- /dev/null
+++ b/docs/build-modules/write-a-driver-module.md
@@ -0,0 +1,1077 @@
+---
+linkTitle: "Write a driver module"
+title: "Write a driver module"
+weight: 20
+layout: "docs"
+type: "docs"
+description: "Build a module that implements a resource API and runs as a separate process."
+date: "2025-01-30"
+aliases:
+ - /build/development/write-a-module/
+ - /development/write-a-module/
+ - /development/write-a-driver-module/
+ - /registry/create/
+ - /use-cases/create-module/
+ - /how-tos/create-module/
+ - /how-tos/sensor-module/
+ - /registry/advanced/iterative-development/
+ - /build/program/extend/modular-resources/
+ - /program/extend/modular-resources/
+ - /extend/
+ - /extend/modular-resources/
+ - /extend/modular-resources/create/
+ - /build/program/extend/modular-resources/key-concepts/
+ - /modular-resources/key-concepts/
+ - /modular-resources/
+ - /extend/modular-resources/examples/custom-arm/
+ - /modular-resources/examples/custom-arm/
+ - /registry/examples/custom-arm/
+ - /program/extend/modular-resources/examples/
+ - /extend/modular-resources/examples/
+ - /modular-resources/examples/
+ - /registry/examples/
+ - /how-tos/hello-world-module/
+---
+
+You want to use hardware that Viam doesn't support out of the box. A driver
+module integrates it with the platform by implementing a standard resource API
+(sensor, camera, motor, or any other type). Once your hardware speaks a Viam
+API, data capture, TEST cards, the SDKs, and other platform features work
+with it automatically.
+
+A driver module runs as a separate process alongside `viam-server`. It has its
+own dependencies, can crash without affecting `viam-server`, and can be
+packaged and distributed through the Viam registry.
+
+For background on choosing a resource API, module lifecycle, and dependencies,
+see the [overview](/build-modules/overview/).
+
+## Steps
+
+When writing a module, follow the steps outlined below. To illustrate each step we'll use a sensor module as a worked example. The same patterns
+apply to any resource type -- substitute the appropriate API and methods for
+your use case.
+
+### 1. Generate the module
+
+Run the Viam CLI generator:
+
+```bash
+viam module generate
+```
+
+| Prompt | What to enter | Why |
+| ---------------- | --------------------------- | -------------------------------- |
+| Module name | `my-sensor-module` | A short, descriptive name |
+| Language | `python` or `go` | Your implementation language |
+| Visibility | `private` | Keep it private while developing |
+| Namespace | Your organization namespace | Scopes the module to your org |
+| Resource subtype | `sensor` | The resource API to implement |
+| Model name | `my-sensor` | The model name for your sensor |
+| Register | `yes` | Registers the module with Viam |
+
+The generator creates a complete project with the following files:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+| File | Purpose |
+| ------------------------------ | --------------------------------------------- |
+| `src/main.py` | Entry point -- starts the module server |
+| `src/models/my_sensor.py` | Resource class skeleton -- you will edit this |
+| `requirements.txt` | Python dependencies |
+| `meta.json` | Module metadata for the registry |
+| `setup.sh` | Installs dependencies into a virtualenv |
+| `build.sh` | Packages the module for upload |
+| `.github/workflows/deploy.yml` | CI workflow for cloud builds |
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+| File | Purpose |
+| ------------------------------ | ------------------------------------------------------ |
+| `cmd/module/main.go` | Entry point -- starts the module server |
+| `my_sensor_module.go` | Resource implementation skeleton -- you will edit this |
+| `go.mod` | Go module definition |
+| `Makefile` | Build targets |
+| `meta.json` | Module metadata for the registry |
+| `.github/workflows/deploy.yml` | CI workflow for cloud builds |
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 2. Implement the resource API
+
+Open the generated resource file. The generator creates a class (Python) or
+struct (Go) with stub methods. You need to make three changes:
+
+1. Define your config attributes.
+2. Add validation logic.
+3. Implement the API methods for your resource type.
+
+The following example builds a sensor that reads temperature and humidity from
+a custom HTTP API endpoint. Replace the HTTP call with whatever data source
+your sensor uses.
+
+#### Define your config attributes
+
+Config attributes are the fields a user sets when they configure your component
+in the Viam app. The generator creates an empty config; add a field for each
+attribute your module needs.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+In `src/models/my_sensor.py`, add instance attributes to your class. These will
+be set in the `reconfigure` method:
+
+```python
+class MySensor(Sensor, EasyResource):
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "my-sensor-module"), "my-sensor"
+ )
+
+ # Add your config attributes as instance variables
+ source_url: str
+ poll_interval: float
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+In the generated `.go` file, find the empty `Config` struct and add fields.
+Each field needs a `json` tag that matches the attribute name users will set in
+their config JSON:
+
+```go
+type Config struct {
+ SourceURL string `json:"source_url"`
+ PollInterval float64 `json:"poll_interval"`
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+#### Add validation logic
+
+The generator creates an empty validation method. Add checks for required
+fields and return any [dependencies](/build-modules/dependencies/) your module needs.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Find `validate_config` in your class and add validation:
+
+```python
+ @classmethod
+ def validate_config(
+ cls, config: ComponentConfig
+ ) -> Tuple[Sequence[str], Sequence[str]]:
+ fields = config.attributes.fields
+ if "source_url" not in fields:
+ raise Exception("source_url is required")
+ if not fields["source_url"].string_value.startswith("http"):
+ raise Exception("source_url must be an HTTP or HTTPS URL")
+ return [], [] # No required or optional dependencies
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Find the `Validate` method on your `Config` struct and add validation:
+
+```go
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ if cfg.SourceURL == "" {
+ return nil, nil, fmt.Errorf("source_url is required")
+ }
+ return nil, nil, nil // No required or optional dependencies
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+The validation method returns two lists: required dependencies and optional
+dependencies. For a standalone sensor with no dependencies, return empty lists.
+See [Step 5](#5-handle-dependencies) for modules that depend on other
+components.
+
+#### Implement the constructor and reconfigure method
+
+The constructor creates your resource and the reconfigure method updates it when
+the config changes. In Python, the typical pattern is for `new` to call
+`reconfigure` so the config-reading logic lives in one place.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Update `new` and add a `reconfigure` method:
+
+```python
+ @classmethod
+ def new(cls, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
+ sensor = cls(config.name)
+ sensor.reconfigure(config, dependencies)
+ return sensor
+
+ def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ fields = config.attributes.fields
+ self.source_url = fields["source_url"].string_value
+ self.poll_interval = (
+ fields["poll_interval"].number_value
+ if "poll_interval" in fields
+ else 10.0
+ )
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Find the generated constructor function. Update it to read your config fields
+and initialize your struct:
+
+```go
+func newMySensor(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+ logger logging.Logger,
+) (sensor.Sensor, error) {
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return nil, err
+ }
+
+ timeout := time.Duration(cfg.PollInterval) * time.Second
+ if timeout == 0 {
+ timeout = 10 * time.Second
+ }
+
+ return &MySensor{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ sourceURL: cfg.SourceURL,
+ client: &http.Client{Timeout: timeout},
+ }, nil
+}
+```
+
+You will also need to add fields to the generated struct for any state your
+module needs at runtime:
+
+```go
+type MySensor struct {
+ resource.Named
+ resource.AlwaysRebuild
+ logger logging.Logger
+ sourceURL string
+ client *http.Client
+}
+```
+
+The generated struct includes `resource.AlwaysRebuild`, which tells
+`viam-server` to destroy and re-create the resource on every config change.
+This is the simplest approach and works well for most modules. For in-place
+reconfiguration, see [Step 6](#6-handle-reconfiguration-optional).
+
+{{% /tab %}}
+{{< /tabs >}}
+
+#### Implement the API method
+
+For a sensor, the key method is `GetReadings`, which returns a map of reading
+names to values. This is the method that data capture calls and your application
+code queries.
+
+The generator creates a stub that returns an error. Replace it with your
+implementation:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Add a `get_readings` method to your class. The return type is
+`Mapping[str, SensorReading]` (import `SensorReading` from `viam.utils`):
+
+```python
+ async def get_readings(
+ self,
+ *,
+ extra: Optional[Mapping[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs,
+ ) -> Mapping[str, SensorReading]:
+ try:
+ response = requests.get(self.source_url, timeout=5)
+ response.raise_for_status()
+ data = response.json()
+ return {
+ "temperature": data["temp"],
+ "humidity": data["humidity"],
+ }
+ except requests.RequestException as e:
+ self.logger.error(f"Failed to read from {self.source_url}: {e}")
+ return {"error": str(e)}
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Find the `Readings` method stub and replace it:
+
+```go
+type sensorResponse struct {
+ Temp float64 `json:"temp"`
+ Humidity float64 `json:"humidity"`
+}
+
+func (s *MySensor) Readings(
+ ctx context.Context,
+ extra map[string]interface{},
+) (map[string]interface{}, error) {
+ resp, err := s.client.Get(s.sourceURL)
+ if err != nil {
+ s.logger.CErrorw(ctx, "failed to read from source",
+ "url", s.sourceURL, "error", err)
+ return nil, fmt.Errorf("failed to read from %s: %w", s.sourceURL, err)
+ }
+ defer resp.Body.Close()
+
+ var data sensorResponse
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return map[string]interface{}{
+ "temperature": data.Temp,
+ "humidity": data.Humidity,
+ }, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+{{< expand "View the complete resource file" >}}
+
+For reference, here is the complete resource file after all the changes above.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+`src/models/my_sensor.py`:
+
+```python
+import requests
+from typing import Any, ClassVar, Mapping, Optional, Sequence, Self, Tuple
+
+from viam.components.sensor import Sensor
+from viam.proto.app.robot import ComponentConfig
+from viam.proto.common import ResourceName
+from viam.resource.base import ResourceBase
+from viam.resource.easy_resource import EasyResource
+from viam.resource.types import Model, ModelFamily
+from viam.utils import SensorReading
+
+
+class MySensor(Sensor, EasyResource):
+ """A custom sensor that reads from an HTTP endpoint."""
+
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "my-sensor-module"), "my-sensor"
+ )
+
+ source_url: str
+ poll_interval: float
+
+ @classmethod
+ def new(cls, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
+ sensor = cls(config.name)
+ sensor.reconfigure(config, dependencies)
+ return sensor
+
+ @classmethod
+ def validate_config(
+ cls, config: ComponentConfig
+ ) -> Tuple[Sequence[str], Sequence[str]]:
+ fields = config.attributes.fields
+ if "source_url" not in fields:
+ raise Exception("source_url is required")
+ if not fields["source_url"].string_value.startswith("http"):
+ raise Exception("source_url must be an HTTP or HTTPS URL")
+ return [], []
+
+ def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ fields = config.attributes.fields
+ self.source_url = fields["source_url"].string_value
+ self.poll_interval = (
+ fields["poll_interval"].number_value
+ if "poll_interval" in fields
+ else 10.0
+ )
+
+ async def get_readings(
+ self,
+ *,
+ extra: Optional[Mapping[str, Any]] = None,
+ timeout: Optional[float] = None,
+ **kwargs,
+ ) -> Mapping[str, SensorReading]:
+ try:
+ response = requests.get(self.source_url, timeout=5)
+ response.raise_for_status()
+ data = response.json()
+ return {
+ "temperature": data["temp"],
+ "humidity": data["humidity"],
+ }
+ except requests.RequestException as e:
+ self.logger.error(f"Failed to read from {self.source_url}: {e}")
+ return {"error": str(e)}
+
+ async def close(self):
+ self.logger.info("Shutting down MySensor")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+`my_sensor_module.go`:
+
+```go
+package mysensormodule
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "go.viam.com/rdk/components/sensor"
+ "go.viam.com/rdk/logging"
+ "go.viam.com/rdk/resource"
+)
+
+var Model = resource.NewModel("my-org", "my-sensor-module", "my-sensor")
+
+type Config struct {
+ SourceURL string `json:"source_url"`
+ PollInterval float64 `json:"poll_interval"`
+}
+
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ if cfg.SourceURL == "" {
+ return nil, nil, fmt.Errorf("source_url is required")
+ }
+ return nil, nil, nil
+}
+
+func init() {
+ resource.RegisterComponent(sensor.API, Model,
+ resource.Registration[sensor.Sensor, *Config]{
+ Constructor: newMySensor,
+ },
+ )
+}
+
+type MySensor struct {
+ resource.Named
+ resource.AlwaysRebuild
+ logger logging.Logger
+ sourceURL string
+ client *http.Client
+}
+
+func newMySensor(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+ logger logging.Logger,
+) (sensor.Sensor, error) {
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return nil, err
+ }
+
+ timeout := time.Duration(cfg.PollInterval) * time.Second
+ if timeout == 0 {
+ timeout = 10 * time.Second
+ }
+
+ return &MySensor{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ sourceURL: cfg.SourceURL,
+ client: &http.Client{Timeout: timeout},
+ }, nil
+}
+
+type sensorResponse struct {
+ Temp float64 `json:"temp"`
+ Humidity float64 `json:"humidity"`
+}
+
+func (s *MySensor) Readings(
+ ctx context.Context,
+ extra map[string]interface{},
+) (map[string]interface{}, error) {
+ resp, err := s.client.Get(s.sourceURL)
+ if err != nil {
+ s.logger.CErrorw(ctx, "failed to read from source",
+ "url", s.sourceURL, "error", err)
+ return nil, fmt.Errorf("failed to read from %s: %w", s.sourceURL, err)
+ }
+ defer resp.Body.Close()
+
+ var data sensorResponse
+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
+ return nil, fmt.Errorf("failed to decode response: %w", err)
+ }
+
+ return map[string]interface{}{
+ "temperature": data.Temp,
+ "humidity": data.Humidity,
+ }, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+{{< /expand >}}
+
+#### Understanding the entry point
+
+The generator also creates the entry point file that `viam-server` launches.
+You typically do not need to modify it.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+`src/main.py`:
+
+```python
+import asyncio
+from viam.module.module import Module
+from models.my_sensor import MySensor # noqa: F401
+
+
+if __name__ == "__main__":
+ asyncio.run(Module.run_from_registry())
+```
+
+`run_from_registry()` automatically discovers all imported resource classes and
+registers them with `viam-server`. If you add more models to your module, import
+them here.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+`cmd/module/main.go`:
+
+```go
+package main
+
+import (
+ mysensormodule "my-org/my-sensor-module"
+ "go.viam.com/rdk/components/sensor"
+ "go.viam.com/rdk/module"
+ "go.viam.com/rdk/resource"
+)
+
+func main() {
+ module.ModularMain(resource.APIModel{sensor.API, mysensormodule.Model})
+}
+```
+
+`ModularMain` handles socket parsing, signal handling, and graceful shutdown.
+The import of the resource package triggers its `init()` function, which calls
+`resource.RegisterComponent` to register the model. If you add more models,
+add more `resource.APIModel` entries to the `ModularMain` call.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 3. Test locally
+
+Use the CLI to build and deploy your module to a machine, then verify it works.
+
+**Deploy with hot reloading:**
+
+```bash
+# Build in the cloud and deploy to the machine
+viam module reload --part-id
+```
+
+If your development machine and target machine share the same architecture (for example, both are `linux/arm64`), you can build locally instead:
+
+```bash
+# Build locally and transfer to the machine
+viam module reload-local --part-id
+```
+
+Use `reload` (cloud build) when developing on a different architecture than your target, for example when developing on macOS and deploying to a Raspberry Pi. Use `reload-local` when architectures match for faster iteration.
+
+After deploying, configure the component's attributes in the Viam app:
+
+```json
+{
+ "source_url": "https://api.example.com/sensor/data"
+}
+```
+
+Click **Save**.
+
+**Test using the Test section:**
+
+1. Find your sensor component and expand the **Test** section.
+2. Your temperature and humidity values appear automatically under
+ **GetReadings**.
+
+**Get a ready-to-run code sample:**
+
+The **CONNECT** tab on your machine's page in the Viam app provides generated
+code samples in Python and Go that connect to your machine and access all
+configured components. Use this as a starting point for application code that
+interacts with your module.
+
+**Rebuild and redeploy during development:**
+
+Each time you make changes, run `viam module reload` (or `reload-local`) again. Use `--no-build` to skip the build step if you already built manually. Use `viam module restart` to restart without rebuilding (for example, after editing Python source).
+
+### 4. Add logging
+
+Both the Python and Go SDKs provide a logger that writes to `viam-server`'s log
+stream, visible in the **LOGS** tab.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+self.logger.info("Sensor initialized with source URL: %s", self.source_url)
+self.logger.debug("Raw response from source: %s", data)
+self.logger.warning("Source returned unexpected field: %s", field_name)
+self.logger.error("Failed to connect to source: %s", error)
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+s.logger.CInfof(ctx, "Sensor initialized with source URL: %s", s.sourceURL)
+s.logger.CDebugf(ctx, "Raw response from source: %v", data)
+s.logger.CWarnw(ctx, "Source returned unexpected field", "field", fieldName)
+s.logger.CErrorw(ctx, "Failed to connect to source", "error", err)
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+Use `info` for significant events, `debug` for detailed data, `warning` for
+recoverable problems, and `error` for failures.
+
+### 5. Handle dependencies
+
+Many modules need access to other resources on the same machine. To use
+another resource, you need to do three things:
+
+1. **Declare the dependency** in your config validation method by returning the
+ resource name in the required (or optional) dependencies list.
+2. **Resolve the dependency** in your constructor or reconfigure method by
+ looking it up from the `dependencies` map that `viam-server` passes in.
+3. **Call methods on it** in your API implementation, just like any other
+ typed resource.
+
+The following example shows all three. It implements a sensor that depends on
+another sensor -- it reads Celsius temperature readings from the source sensor
+and converts them to Fahrenheit. Watch for the numbered comments in the code:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+class TempConverterSensor(Sensor, EasyResource):
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "my-sensor-module"), "temp-converter"
+ )
+
+ source_sensor: Sensor
+
+ @classmethod
+ def validate_config(
+ cls, config: ComponentConfig
+ ) -> Tuple[Sequence[str], Sequence[str]]:
+ fields = config.attributes.fields
+ if "source_sensor" not in fields:
+ raise Exception("source_sensor is required")
+ source = fields["source_sensor"].string_value
+ # 1. Declare: return the source sensor name as a required dependency
+ return [source], []
+
+ def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ source_name = config.attributes.fields["source_sensor"].string_value
+ # 2. Resolve: find the dependency in the map viam-server passes in
+ for name, dep in dependencies.items():
+ if name.name == source_name:
+ self.source_sensor = dep
+ break
+
+ async def get_readings(self, *, extra=None, timeout=None,
+ **kwargs) -> Mapping[str, SensorReading]:
+ # 3. Use: call methods on the dependency like any typed resource
+ readings = await self.source_sensor.get_readings()
+ celsius = readings["temperature"]
+ return {"temperature_f": celsius * 9.0 / 5.0 + 32.0}
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+type ConverterConfig struct {
+ SourceSensor string `json:"source_sensor"`
+}
+
+func (cfg *ConverterConfig) Validate(path string) ([]string, []string, error) {
+ if cfg.SourceSensor == "" {
+ return nil, nil, fmt.Errorf("source_sensor is required")
+ }
+ // 1. Declare: return the source sensor name as a required dependency
+ return []string{cfg.SourceSensor}, nil, nil
+}
+
+type TempConverterSensor struct {
+ resource.Named
+ resource.AlwaysRebuild
+ logger logging.Logger
+ source sensor.Sensor
+}
+
+func newTempConverter(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+ logger logging.Logger,
+) (sensor.Sensor, error) {
+ cfg, err := resource.NativeConfig[*ConverterConfig](conf)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. Resolve: look up the dependency by name from the map viam-server passes in
+ src, err := sensor.FromProvider(deps, cfg.SourceSensor)
+ if err != nil {
+ return nil, fmt.Errorf("source sensor %q not found: %w",
+ cfg.SourceSensor, err)
+ }
+
+ return &TempConverterSensor{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ source: src,
+ }, nil
+}
+
+func (s *TempConverterSensor) Readings(
+ ctx context.Context,
+ extra map[string]interface{},
+) (map[string]interface{}, error) {
+ // 3. Use: call methods on the dependency like any typed resource
+ readings, err := s.source.Readings(ctx, extra)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read source sensor: %w", err)
+ }
+ celsius, ok := readings["temperature"].(float64)
+ if !ok {
+ return nil, fmt.Errorf("source sensor did not return a temperature reading")
+ }
+ return map[string]interface{}{
+ "temperature_f": celsius*9.0/5.0 + 32.0,
+ }, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 6. Handle reconfiguration (optional)
+
+When a user changes a resource's configuration, `viam-server` calls your
+reconfiguration method instead of destroying and re-creating the resource.
+This is faster and preserves internal state like open connections.
+
+The generated code uses `resource.AlwaysRebuild` (Go) by default, which
+tells `viam-server` to destroy and re-create the resource on every config
+change. This is the simplest approach and works well for most modules.
+
+For in-place reconfiguration, implement the reconfiguration method to update
+your resource's fields directly:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Implement `reconfigure` to update your resource from the new config. This
+method is also typically called from `new` (see the example in Step 2):
+
+```python
+def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ fields = config.attributes.fields
+ self.source_url = fields["source_url"].string_value
+ self.poll_interval = (
+ fields["poll_interval"].number_value
+ if "poll_interval" in fields
+ else 10.0
+ )
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Remove `resource.AlwaysRebuild` from your struct and implement `Reconfigure`:
+
+```go
+type MySensor struct {
+ resource.Named
+ logger logging.Logger
+ sourceURL string
+ client *http.Client
+}
+
+func (s *MySensor) Reconfigure(ctx context.Context,
+ deps resource.Dependencies, conf resource.Config) error {
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return err
+ }
+ s.sourceURL = cfg.SourceURL
+ s.client.Timeout = time.Duration(cfg.PollInterval) * time.Second
+ return nil
+}
+```
+
+Go provides these helper traits as alternatives to writing a `Reconfigure`
+method. Embed one in your struct:
+
+| Trait | Behavior |
+| ---------------------------------- | ------------------------------------------------------------------------------------ |
+| `resource.AlwaysRebuild` | Resource is destroyed and re-created on every config change (the generated default). |
+| `resource.TriviallyReconfigurable` | Config changes are accepted silently with no action. |
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 7. Use the module data directory
+
+Every module gets a persistent data directory at the path specified by the
+`VIAM_MODULE_DATA` environment variable. Use this for caches, databases, or
+any state that should survive module restarts.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+import os
+
+data_dir = os.environ.get("VIAM_MODULE_DATA", "/tmp")
+cache_path = os.path.join(data_dir, "readings_cache.json")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+dataDir := os.Getenv("VIAM_MODULE_DATA")
+cachePath := filepath.Join(dataDir, "readings_cache.json")
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+The directory is created automatically by `viam-server` at
+`$VIAM_HOME/module-data///` (where `VIAM_HOME`
+defaults to `~/.viam`) and persists across module
+restarts and reconfigurations.
+
+### 8. Add multiple models to one module (optional)
+
+A single module can provide multiple models, even across different APIs
+(for example, a sensor and a camera). There is no limit on the number of models
+per module.
+
+To add a second model:
+
+1. Run `viam module generate` again in a separate directory to generate
+ the new model's scaffolding. Do not register it -- you only need the
+ generated resource file.
+2. Copy the generated resource file into your existing module's source
+ directory.
+3. Update the entry point to register both models.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Import the new model in `src/main.py`. `run_from_registry()` automatically
+discovers all imported resource classes:
+
+```python
+import asyncio
+from viam.module.module import Module
+from models.my_sensor import MySensor # noqa: F401
+from models.my_camera import MyCamera # noqa: F401
+
+if __name__ == "__main__":
+ asyncio.run(Module.run_from_registry())
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Add more `resource.APIModel` entries to `ModularMain`:
+
+```go
+package main
+
+import (
+ mymodule "my-org/my-module"
+ "go.viam.com/rdk/components/camera"
+ "go.viam.com/rdk/components/sensor"
+ "go.viam.com/rdk/module"
+ "go.viam.com/rdk/resource"
+)
+
+func main() {
+ module.ModularMain(
+ resource.APIModel{sensor.API, mymodule.SensorModel},
+ resource.APIModel{camera.API, mymodule.CameraModel},
+ )
+}
+```
+
+Each model needs its own `init()` function calling `resource.RegisterComponent`
+(or `resource.RegisterService`) with its API, model, and constructor.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+4. Delete the temporary generated directory.
+5. Update `meta.json` to list all models (or use `viam module update-models
+--binary ./bin/module` to detect them automatically from a Go binary).
+
+## Try It
+
+1. Generate a sensor module using `viam module generate`.
+2. Open the generated resource file and implement your config validation and
+ `GetReadings` method.
+3. Configure the module on your machine as a local module.
+4. Expand the **Test** section on your sensor. Verify readings appear
+ automatically under **GetReadings**.
+5. Enable data capture on the sensor. Wait one minute, then check the **DATA**
+ tab to confirm readings are flowing to the cloud.
+6. Add a new key to the readings map (for example, `"pressure": 1013.25`).
+ Rebuild and redeploy with `viam module reload-local`. Verify the new reading
+ appears on the TEST card.
+
+## Troubleshooting
+
+{{< expand "Module crashes on startup" >}}
+
+- Check the **LOGS** tab for the crash traceback. The most common cause is a
+ missing dependency -- a Python import not in `requirements.txt` or a Go
+ package not in `go.mod`.
+- For Python, verify the module runs outside of `viam-server`:
+ `python3 -m src.main` (from your module directory, with the virtualenv
+ activated).
+- For Go, verify the binary runs: `./bin/` (the output path
+ is set in your `Makefile`).
+- Check that your entrypoint script has execute permissions: `chmod +x run.sh`.
+
+{{< /expand >}}
+
+{{< expand "Module times out on startup" >}}
+
+`viam-server` expects the module to complete startup within 5 minutes (the
+default `VIAM_MODULE_STARTUP_TIMEOUT`). If your module does heavy
+initialization (loading large files, connecting to slow services), it may
+time out.
+
+- Move slow initialization out of `init()` or model registration and into the
+ constructor instead, where it runs per-resource rather than blocking module
+ startup.
+- Check the **LOGS** tab for timeout errors.
+
+{{< /expand >}}
+
+{{< expand "Dependency not found" >}}
+
+- Confirm the dependency name returned by your config validation method matches
+ the resource name on the machine exactly (names are compared as strings, so
+ case and spelling must match).
+- Verify the depended-on resource exists and is configured correctly.
+- Check for circular dependencies -- if A depends on B and B depends on A,
+ both will fail to start. Check the **LOGS** tab for "circular dependency"
+ errors.
+
+{{< /expand >}}
+
+{{< expand "Readings returning None or nil" >}}
+
+- Add logging inside your `GetReadings` implementation to see what data your
+ source returns.
+- `GetReadings` must return a non-nil map. If it returns `nil` (Go) or `None`
+ (Python), `viam-server` treats this as an error.
+- Check network connectivity from the machine if your sensor reads from an
+ external source.
+
+{{< /expand >}}
+
+{{< expand "Module not restarting after code changes" >}}
+
+`viam-server` does not watch your module's source files or binary for changes.
+To deploy changes:
+
+- Use `viam module reload-local --part-id ` to rebuild and redeploy.
+- Use `viam module restart --part-id ` to restart without rebuilding.
+
+If `reload-local` fails:
+
+- **"no build command"** -- Your `meta.json` is missing a `build.build` field.
+ Add the path to your build script (for example, `"build": "./build.sh"`).
+- **"could not find module ID"** -- Run the command from the directory
+ containing `meta.json`, or use `--home ` to specify the module
+ directory.
+- **PermissionDenied errors** -- Try `--home $HOME` to ensure the CLI can
+ locate the module metadata.
+
+{{< /expand >}}
+
+{{< expand "Data capture not recording readings" >}}
+
+Data capture requires both the data management service and a per-resource
+capture configuration:
+
+- Verify the data management service is configured and does not have
+ `capture_disabled` set to `true`.
+- Verify your sensor component has a data capture configuration with
+ `capture_frequency_hz` greater than `0`.
+- Check that `GetReadings` returns a valid, non-nil map.
+- If the capture frequency is very low, you may need to wait longer to see
+ data appear on the [**Data** page](https://app.viam.com/data).
+
+{{< /expand >}}
+
+## What's Next
+
+- [Write a Logic Module](/build-modules/write-a-logic-module/) -- write a module
+ that monitors sensors, coordinates components, or runs automation logic.
+- [Deploy a Module](/build-modules/deploy-a-module/) -- package your module
+ and upload it to the Viam registry for distribution.
+- [Module Reference](/build-modules/module-reference/) -- complete reference
+ for meta.json, CLI commands, environment variables, and resource interfaces.
diff --git a/docs/build-modules/write-a-logic-module.md b/docs/build-modules/write-a-logic-module.md
new file mode 100644
index 0000000000..8edf4881bb
--- /dev/null
+++ b/docs/build-modules/write-a-logic-module.md
@@ -0,0 +1,690 @@
+---
+linkTitle: "Write a logic module"
+title: "Write a logic module"
+weight: 25
+layout: "docs"
+type: "docs"
+description: "Build a module that monitors sensors, coordinates components, or runs automation logic."
+date: "2025-03-06"
+aliases:
+ - /development/write-a-logic-module/
+ - /manage/software/control-logic
+---
+
+Your machine has resources -- sensors, motors, cameras -- that work individually.
+A logic module makes them work together. It runs as a service alongside
+`viam-server`, declares dependencies on the resources it needs, and implements
+whatever control, monitoring, or coordination logic your application requires.
+
+Use a logic module when you need your machine to make decisions based on what
+it senses: trigger actions when readings cross a threshold, coordinate multiple
+components to accomplish a task, aggregate data from several sources, or run
+any continuous process that reads from some resources and acts on others.
+
+{{< alert title="Driver modules and logic modules" color="tip" >}}
+A [driver module](/build-modules/write-a-driver-module/) wraps hardware -- it implements
+a component API like sensor or motor so that `viam-server` can talk to a
+specific piece of hardware.
+
+A logic module (this page) orchestrates existing resources -- it reads from
+sensors, commands motors, and makes decisions. It typically implements a service
+API.
+
+Both are modules. The difference is what they do, not how they're built. The
+lifecycle, config validation, dependency, and deployment patterns are the same.
+{{< /alert >}}
+
+For background on the generic service API, module lifecycle, dependencies, and
+background tasks, see the [overview](/build-modules/overview/).
+
+## Steps
+
+When writing a logic module, follow the steps outlined below. To illustrate
+each step we'll use a temperature alert monitor as a worked example. It watches
+one or more sensors, compares their readings against configurable thresholds,
+and maintains a list of active alerts that your application code can query.
+
+### 1. Generate a generic service module
+
+```bash
+viam module generate
+```
+
+| Prompt | What to enter | Why |
+| ---------------- | --------------------------- | -------------------------------- |
+| Module name | `alert-monitor` | A short, descriptive name |
+| Language | `python` or `go` | Your implementation language |
+| Visibility | `private` | Keep it private while developing |
+| Namespace | Your organization namespace | Scopes the module to your org |
+| Resource subtype | `generic` (under services) | Flexible service API |
+| Model name | `temp-alert` | The model name for your service |
+| Register | `yes` | Registers the module with Viam |
+
+The generator creates a complete project. The key files you will edit:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+| File | Purpose |
+| -------------------------- | ----------------------------------------------------------- |
+| `src/models/temp_alert.py` | Service class skeleton -- you will edit this |
+| `src/main.py` | Entry point -- starts the module server (no changes needed) |
+| `meta.json` | Module metadata for the registry |
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+| File | Purpose |
+| -------------------- | ----------------------------------------------------------- |
+| `alert_monitor.go` | Service implementation skeleton -- you will edit this |
+| `cmd/module/main.go` | Entry point -- starts the module server (no changes needed) |
+| `meta.json` | Module metadata for the registry |
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 2. Define the config
+
+Open the generated resource file. Define config attributes for the sensors to
+monitor and the alert thresholds.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+In `src/models/temp_alert.py`, add config attributes to your class:
+
+```python
+class TempAlert(Generic, EasyResource):
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "alert-monitor"), "temp-alert"
+ )
+
+ sensor_names: list[str]
+ max_temp: float
+ poll_interval: float
+ alerts: list[dict]
+ _monitor_task: Optional[asyncio.Task]
+ _stop_event: asyncio.Event
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+In the generated `.go` file, add fields to the `Config` struct. Each field
+needs a `json` tag matching the attribute name users set in their config JSON.
+
+Then update the `Validate` method. It returns three values: a list of required
+dependency names, a list of optional dependency names, and an error.
+
+```go
+type Config struct {
+ SensorNames []string `json:"sensor_names"`
+ MaxTemp float64 `json:"max_temp"`
+ PollInterval float64 `json:"poll_interval_secs"`
+}
+
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ if len(cfg.SensorNames) == 0 {
+ return nil, nil, fmt.Errorf("sensor_names is required")
+ }
+ if cfg.MaxTemp == 0 {
+ return nil, nil, fmt.Errorf("max_temp is required")
+ }
+ // 1. Declare: return all sensor names as required dependencies
+ return cfg.SensorNames, nil, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 3. Implement the constructor
+
+The constructor receives the validated config and a `dependencies` map
+containing the resources you declared in the validation method. Look up each
+dependency by name, store it on your struct/instance, and start the background
+monitoring loop.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+Update `validate_config`, `new`, and `reconfigure`:
+
+```python
+ @classmethod
+ def validate_config(
+ cls, config: ComponentConfig
+ ) -> Tuple[Sequence[str], Sequence[str]]:
+ fields = config.attributes.fields
+ if "sensor_names" not in fields:
+ raise Exception("sensor_names is required")
+ if "max_temp" not in fields:
+ raise Exception("max_temp is required")
+ sensor_names = [
+ v.string_value
+ for v in fields["sensor_names"].list_value.values
+ ]
+ # 1. Declare: return sensor names as required dependencies
+ return sensor_names, []
+
+ @classmethod
+ def new(cls, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> Self:
+ instance = cls(config.name)
+ instance.alerts = []
+ instance._monitor_task = None
+ instance._stop_event = asyncio.Event()
+ instance.reconfigure(config, dependencies)
+ return instance
+
+ def reconfigure(self, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]) -> None:
+ # Stop any existing monitor loop
+ if self._monitor_task is not None:
+ self._stop_event.set()
+ self._monitor_task = None
+
+ fields = config.attributes.fields
+ self.sensor_names = [
+ v.string_value
+ for v in fields["sensor_names"].list_value.values
+ ]
+ self.max_temp = fields["max_temp"].number_value
+ self.poll_interval = (
+ fields["poll_interval_secs"].number_value
+ if "poll_interval_secs" in fields
+ else 10.0
+ )
+
+ # 2. Resolve: find each sensor in the dependencies map
+ self.sensors = {}
+ for name, dep in dependencies.items():
+ if name.name in self.sensor_names:
+ self.sensors[name.name] = dep
+
+ # Start the monitor loop
+ self._stop_event = asyncio.Event()
+ self._monitor_task = asyncio.create_task(self._monitor_loop())
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+Update the struct and constructor. `resource.Named` provides the `Name()`
+method that `viam-server` requires. `resource.NativeConfig` converts the raw
+config into your typed struct. `sensor.FromProvider` looks up a sensor
+dependency by name from the dependencies map.
+
+```go
+type TempAlert struct {
+ resource.Named
+ logger logging.Logger
+ cfg *Config
+ sensors map[string]sensor.Sensor
+ mu sync.Mutex
+ alerts []Alert
+ cancelFn func()
+}
+
+type Alert struct {
+ Sensor string `json:"sensor"`
+ Value float64 `json:"value"`
+ Threshold float64 `json:"threshold"`
+ Time string `json:"time"`
+}
+
+func newTempAlert(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+ logger logging.Logger,
+) (resource.Resource, error) {
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return nil, err
+ }
+
+ // 2. Resolve: find each sensor in the dependencies map
+ sensors := make(map[string]sensor.Sensor)
+ for _, name := range cfg.SensorNames {
+ s, err := sensor.FromProvider(deps, name)
+ if err != nil {
+ return nil, fmt.Errorf("sensor %q not found: %w", name, err)
+ }
+ sensors[name] = s
+ }
+
+ monitorCtx, cancelFn := context.WithCancel(context.Background())
+ svc := &TempAlert{
+ Named: conf.ResourceName().AsNamed(),
+ logger: logger,
+ cfg: cfg,
+ sensors: sensors,
+ alerts: []Alert{},
+ cancelFn: cancelFn,
+ }
+
+ // Start background monitor loop
+ go svc.monitorLoop(monitorCtx)
+
+ return svc, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 4. Implement the background loop
+
+The monitor loop polls sensors at a fixed interval and checks readings against
+thresholds. When a reading exceeds the threshold, it creates an alert.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ async def _monitor_loop(self):
+ while not self._stop_event.is_set():
+ for name, s in self.sensors.items():
+ try:
+ # 3. Use: call methods on dependencies
+ readings = await s.get_readings()
+ temp = readings.get("temperature")
+ if temp is not None and temp > self.max_temp:
+ alert = {
+ "sensor": name,
+ "value": temp,
+ "threshold": self.max_temp,
+ "time": datetime.now().isoformat(),
+ }
+ self.alerts.append(alert)
+ self.logger.warning(
+ "Alert: %s reported %.1f (threshold: %.1f)",
+ name, temp, self.max_temp,
+ )
+ except Exception as e:
+ self.logger.error("Failed to read %s: %s", name, e)
+
+ try:
+ await asyncio.wait_for(
+ self._stop_event.wait(),
+ timeout=self.poll_interval,
+ )
+ break # Stop event was set
+ except asyncio.TimeoutError:
+ pass # Continue polling
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (s *TempAlert) monitorLoop(ctx context.Context) {
+ interval := time.Duration(s.cfg.PollInterval) * time.Second
+ if interval == 0 {
+ interval = 10 * time.Second
+ }
+
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.checkSensors(ctx)
+ }
+ }
+}
+
+func (s *TempAlert) checkSensors(ctx context.Context) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for name, sens := range s.sensors {
+ // 3. Use: call methods on dependencies
+ readings, err := sens.Readings(ctx, nil)
+ if err != nil {
+ s.logger.CErrorw(ctx, "failed to read sensor", "sensor", name, "error", err)
+ continue
+ }
+ temp, ok := readings["temperature"].(float64)
+ if !ok {
+ continue
+ }
+ if temp > s.cfg.MaxTemp {
+ alert := Alert{
+ Sensor: name,
+ Value: temp,
+ Threshold: s.cfg.MaxTemp,
+ Time: time.Now().Format(time.RFC3339),
+ }
+ s.alerts = append(s.alerts, alert)
+ s.logger.CWarnw(ctx, "alert triggered",
+ "sensor", name, "value", temp, "threshold", s.cfg.MaxTemp)
+ }
+ }
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 5. Implement DoCommand
+
+`DoCommand` is the interface your application code uses to interact with the
+service. Define a command vocabulary that makes sense for your module.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ async def do_command(
+ self,
+ command: Mapping[str, ValueTypes],
+ *,
+ timeout: Optional[float] = None,
+ **kwargs,
+ ) -> Mapping[str, ValueTypes]:
+ cmd = command.get("command", "")
+
+ if cmd == "get_alerts":
+ return {"alerts": self.alerts}
+
+ if cmd == "get_alert_count":
+ return {"count": len(self.alerts)}
+
+ if cmd == "acknowledge":
+ self.alerts.clear()
+ return {"status": "ok"}
+
+ if cmd == "set_threshold":
+ self.max_temp = command["max_temp"]
+ return {"status": "ok", "max_temp": self.max_temp}
+
+ raise Exception(f"Unknown command: {cmd}")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (s *TempAlert) DoCommand(
+ ctx context.Context,
+ cmd map[string]interface{},
+) (map[string]interface{}, error) {
+ command, _ := cmd["command"].(string)
+
+ switch command {
+ case "get_alerts":
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ // Convert alerts to interface slice for serialization
+ alertList := make([]interface{}, len(s.alerts))
+ for i, a := range s.alerts {
+ alertList[i] = map[string]interface{}{
+ "sensor": a.Sensor,
+ "value": a.Value,
+ "threshold": a.Threshold,
+ "time": a.Time,
+ }
+ }
+ return map[string]interface{}{"alerts": alertList}, nil
+
+ case "get_alert_count":
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return map[string]interface{}{"count": len(s.alerts)}, nil
+
+ case "acknowledge":
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.alerts = s.alerts[:0]
+ return map[string]interface{}{"status": "ok"}, nil
+
+ case "set_threshold":
+ newMax, ok := cmd["max_temp"].(float64)
+ if !ok {
+ return nil, fmt.Errorf("max_temp must be a number")
+ }
+ s.mu.Lock()
+ s.cfg.MaxTemp = newMax
+ s.mu.Unlock()
+ return map[string]interface{}{"status": "ok", "max_temp": newMax}, nil
+
+ default:
+ return nil, fmt.Errorf("unknown command: %s", command)
+ }
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 6. Handle shutdown
+
+When `viam-server` stops the module or reconfigures it, your background loop
+must stop cleanly. Without this, goroutines or async tasks leak.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+ async def close(self):
+ self._stop_event.set()
+ if self._monitor_task is not None:
+ await self._monitor_task
+ self._monitor_task = None
+ self.logger.info("TempAlert monitor stopped")
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (s *TempAlert) Close(ctx context.Context) error {
+ s.cancelFn()
+ s.logger.CInfof(ctx, "TempAlert monitor stopped")
+ return nil
+}
+```
+
+In Go, the `Reconfigure` method should also stop the old loop and start a new
+one:
+
+```go
+func (s *TempAlert) Reconfigure(
+ ctx context.Context,
+ deps resource.Dependencies,
+ conf resource.Config,
+) error {
+ // Stop the old loop
+ s.cancelFn()
+
+ cfg, err := resource.NativeConfig[*Config](conf)
+ if err != nil {
+ return err
+ }
+
+ sensors := make(map[string]sensor.Sensor)
+ for _, name := range cfg.SensorNames {
+ sens, err := sensor.FromProvider(deps, name)
+ if err != nil {
+ return fmt.Errorf("sensor %q not found: %w", name, err)
+ }
+ sensors[name] = sens
+ }
+
+ monitorCtx, cancelFn := context.WithCancel(context.Background())
+
+ s.mu.Lock()
+ s.cfg = cfg
+ s.sensors = sensors
+ s.cancelFn = cancelFn
+ s.mu.Unlock()
+
+ go s.monitorLoop(monitorCtx)
+ return nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 7. Test locally
+
+**Deploy with hot reloading:**
+
+Ensure you have at least one sensor configured on your machine (this is the resource your logic module will monitor).
+
+Use the CLI to build and deploy your module:
+
+```bash
+# Build in the cloud and deploy to the machine
+viam module reload --part-id
+```
+
+If your development machine and target machine share the same architecture, you can build locally instead:
+
+```bash
+# Build locally and transfer to the machine
+viam module reload-local --part-id
+```
+
+Use `reload` (cloud build) when developing on a different architecture than your target. Use `reload-local` when architectures match for faster iteration.
+
+After deploying, configure the service's attributes in the Viam app:
+
+```json
+{
+ "sensor_names": ["my-temp-sensor"],
+ "max_temp": 30.0,
+ "poll_interval_secs": 5
+}
+```
+
+Click **Save**.
+
+**Test with DoCommand:**
+
+On the **CONFIGURE** tab, expand your service's **Test** section and then expand **DoCommand**. Send a command:
+
+```json
+{ "command": "get_alerts" }
+```
+
+You should see a response with any alerts that have been triggered.
+
+**Get a ready-to-run code sample:**
+
+The **CONNECT** tab on your machine's page in the Viam app provides generated
+code samples in Python and Go that connect to your machine and access all
+configured resources. Use this as a starting point for application code that
+sends `DoCommand` requests to your service.
+
+**Rebuild and redeploy during development:**
+
+Each time you make changes, run `viam module reload` (or `reload-local`) again. Use `viam module restart` to restart without rebuilding (for example, after editing Python source).
+
+**Test the alert flow:**
+
+1. Verify your sensor is returning temperature readings on the TEST card.
+2. Set `max_temp` to a value below the current temperature so alerts trigger.
+3. Wait for one poll interval, then send `{"command": "get_alerts"}`.
+4. You should see alerts in the response.
+5. Send `{"command": "acknowledge"}` to clear them.
+
+### 8. Schedule logic with jobs (optional)
+
+Instead of running a continuous background loop, you can use
+{{< glossary_tooltip term_id="job" text="jobs" >}} to have `viam-server` call
+your service's `DoCommand` method on a schedule. This is useful for periodic
+tasks that don't need sub-second polling.
+
+1. In the [Viam app](https://app.viam.com), click the **+** icon next to your
+ machine part and select **Job**.
+2. Name the job and click **Create**.
+3. Set the **Schedule** to one of:
+ - **Interval** -- a Go duration string like `5s`, `1m`, or `2h30m`.
+ - **Cron** -- a 5- or 6-part cron expression (for example, `0 */5 * * *`).
+4. Select your service resource by name.
+5. Select the `DoCommand` **Method** and specify the **Command**, for example:
+
+ ```json
+ { "command": "get_alerts" }
+ ```
+
+6. Click **Save**.
+
+`viam-server` calls `DoCommand` with the specified arguments on the configured
+schedule. You can view job history (last 10 successes and failures) in the
+machine's configuration.
+
+Jobs also support calling other gRPC methods on resources (such as
+`GetReadings` on a sensor), but only `DoCommand` accepts command arguments.
+
+## Try It
+
+1. Generate a generic service module using `viam module generate`.
+2. Define config attributes for monitored sensors and thresholds.
+3. Implement the constructor to resolve sensor dependencies and start the
+ monitor loop.
+4. Implement `DoCommand` with `get_alerts`, `acknowledge`, and `set_threshold`
+ commands.
+5. Configure the module on your machine with a real sensor.
+6. Lower the threshold below the current temperature and verify alerts appear.
+
+## Troubleshooting
+
+{{< expand "Background loop not running" >}}
+
+- Check the **LOGS** tab for errors from the monitor loop. A failing sensor
+ read can cause the loop to exit silently.
+- Verify the `poll_interval_secs` is greater than 0.
+- In Python, ensure you are creating an `asyncio.Task` (not just calling the
+ async function without `await` or `create_task`).
+- In Go, ensure the goroutine context is not prematurely canceled. Use
+ `context.Background()` for the loop context, not the request context.
+
+{{< /expand >}}
+
+{{< expand "DoCommand returning unexpected results" >}}
+
+- Verify the `command` field in your request matches the command names in your
+ implementation exactly (case-sensitive).
+- Check that value types match. JSON numbers are `float64` in Go and `float`
+ in Python. If you send `"max_temp": 30`, Go receives it as `float64(30)`.
+- Add logging inside `DoCommand` to see the raw command map.
+
+{{< /expand >}}
+
+{{< expand "Sensor dependency not available" >}}
+
+- Confirm the sensor names in `sensor_names` match the names of sensors
+ configured on the machine exactly.
+- Verify the sensors are configured and working before the logic module starts.
+ Check the **CONTROL** tab to confirm each sensor returns readings.
+- If a sensor is added after the logic module is already running, reconfigure
+ the logic module (re-save its config) so `viam-server` re-resolves
+ dependencies.
+
+{{< /expand >}}
+
+{{< expand "Alerts not appearing" >}}
+
+- Check that your sensor's readings map includes a `"temperature"` key. The
+ monitor loop checks for this specific key.
+- Verify `max_temp` is set below the actual temperature so alerts trigger.
+- Check the **LOGS** tab for warning messages from the monitor loop.
+
+{{< /expand >}}
+
+## What's Next
+
+- [Write a Driver Module](/build-modules/write-a-driver-module/) -- write a module
+ that wraps custom hardware.
+- [Deploy a Module](/build-modules/deploy-a-module/) -- package and upload your
+ module to the Viam registry.
+- [Module Reference](/build-modules/module-reference/) -- complete reference
+ for meta.json, CLI commands, environment variables, and resource interfaces.
diff --git a/docs/build-modules/write-an-inline-module.md b/docs/build-modules/write-an-inline-module.md
new file mode 100644
index 0000000000..2a686696b5
--- /dev/null
+++ b/docs/build-modules/write-an-inline-module.md
@@ -0,0 +1,532 @@
+---
+linkTitle: "Write an inline module"
+title: "Write an inline module"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "Write and deploy a custom module directly in the browser using Viam's inline module editor."
+date: "2025-01-30"
+aliases:
+ - /build/development/write-an-inline-module/
+ - /development/write-an-inline-module/
+---
+
+Viam provides built-in support for many types of hardware and software, but you
+may want to use hardware that Viam doesn't support out of the box, or add
+application-specific logic. Modules let you add that support yourself.
+
+An inline module is the fastest way to get started. You write your module code
+directly in the Viam app's browser-based editor -- no IDE, terminal, or GitHub
+account required. When you click **Save & Deploy**, Viam builds your module in
+the cloud and deploys it to your machine automatically.
+
+{{< alert title="Availability" color="tip" >}}
+Inline modules are currently available to organizations that have the feature
+enabled. If you do not see the **Viam-hosted** option when adding code, contact
+Viam support to request access.
+{{< /alert >}}
+
+For background on inline modules, how they compare to externally managed
+modules, and the Generic service API, see the
+[overview](/build-modules/overview/#inline-and-externally-managed-modules).
+
+## Steps
+
+### 1. Create the module
+
+1. In the [Viam app](https://app.viam.com), navigate to your machine's
+ **CONFIGURE** tab.
+2. Click **+** and select **Control code**.
+3. In the "Choose where to host your code" dialog, select **Viam-hosted** and
+ click **Choose**.
+4. Name your module (for example, `servo-distance-control`) and choose a language
+ (**Python** or **Go**).
+5. Click **Create module**.
+
+The browser opens the code editor with a working template that includes all
+necessary imports and method stubs.
+
+### 2. Understand the template
+
+The editor opens a single file -- your module's main source file. The template
+includes three methods you need to fill in:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+The editable file is `src/models/generic_service.py`. It contains a class that
+extends `GenericService` and `EasyResource`:
+
+```python
+class MyGenericService(GenericService, EasyResource):
+ MODEL: ClassVar[Model] = Model(
+ ModelFamily("my-org", "my-module"), "generic-service"
+ )
+```
+
+The three methods to implement:
+
+- **`validate_config`** -- check that configuration attributes are valid and
+ declare dependencies.
+- **`new`** -- initialize your service with attributes and dependencies.
+- **`do_command`** -- your control logic.
+
+{{< alert title="Important" color="caution" >}}
+Do not change the class name or the `MODEL` triplet. Viam uses these
+auto-generated values to identify your module. Changing them will break your
+inline module.
+{{< /alert >}}
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+The editable file is `module.go`. It contains a struct, a config type, and
+registration logic:
+
+```go
+var GenericService = resource.NewModel(
+ "my-org", "my-module", "generic-service",
+)
+
+func init() {
+ resource.RegisterService(genericservice.API, GenericService,
+ resource.Registration[resource.Resource, *Config]{
+ Constructor: newGenericService,
+ },
+ )
+}
+```
+
+The three areas to implement:
+
+- **`Validate`** on the `Config` struct -- check attributes, return
+ dependencies.
+- **`NewGenericService`** -- initialize the service with attributes and
+ dependencies.
+- **`DoCommand`** -- your control logic.
+
+{{< alert title="Important" color="caution" >}}
+Do not change the model name triplet, struct names, or public function names.
+Viam uses these auto-generated values to identify your module. Changing them
+will break your inline module.
+{{< /alert >}}
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 3. Implement validate_config
+
+The validate method runs every time the machine configuration changes. It checks
+that the attributes passed to your service are valid and declares dependencies
+on other components or services.
+
+This example validates attributes for a distance-responsive servo controller --
+a service that reads an ultrasonic sensor and adjusts a servo angle based on
+distance:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+@classmethod
+def validate_config(
+ cls, config: ComponentConfig
+) -> Tuple[Sequence[str], Sequence[str]]:
+ attrs = struct_to_dict(config.attributes)
+
+ # Required numeric attributes
+ sensor_range_start = attrs.get("sensor_range_start")
+ if sensor_range_start is None or not isinstance(
+ sensor_range_start, (int, float)
+ ):
+ raise ValueError(
+ "attribute 'sensor_range_start' is required "
+ "and must be an int or float value"
+ )
+
+ sensor_range_end = attrs.get("sensor_range_end")
+ if sensor_range_end is None or not isinstance(
+ sensor_range_end, (int, float)
+ ):
+ raise ValueError(
+ "attribute 'sensor_range_end' is required "
+ "and must be an int or float value"
+ )
+
+ # Required dependency attributes
+ required_deps: List[str] = []
+
+ servo_name = attrs.get("servo")
+ if not isinstance(servo_name, str) or not servo_name:
+ raise ValueError(
+ "attribute 'servo' (non-empty string) is required"
+ )
+ required_deps.append(servo_name)
+
+ sensor_name = attrs.get("sensor")
+ if not isinstance(sensor_name, str) or not sensor_name:
+ raise ValueError(
+ "attribute 'sensor' (non-empty string) is required"
+ )
+ required_deps.append(sensor_name)
+
+ return required_deps, []
+```
+
+The return value is a tuple of two lists:
+
+1. **Required dependencies** -- component or service names that must exist and
+ be ready before your service starts.
+2. **Optional dependencies** -- names your service can use if available but
+ does not require.
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+type Config struct {
+ SensorRangeStart float64 `json:"sensor_range_start"`
+ SensorRangeEnd float64 `json:"sensor_range_end"`
+ ServoAngleMin *int64 `json:"servo_angle_min"`
+ ServoAngleMax *int64 `json:"servo_angle_max"`
+ Reversed *bool `json:"reversed"`
+ Servo string `json:"servo"`
+ Sensor string `json:"sensor"`
+}
+
+func (cfg *Config) Validate(path string) ([]string, []string, error) {
+ if cfg.SensorRangeStart == 0 {
+ return nil, nil, fmt.Errorf(
+ "%s: 'sensor_range_start' is required and must be non-zero",
+ path,
+ )
+ }
+ if cfg.SensorRangeEnd == 0 {
+ return nil, nil, fmt.Errorf(
+ "%s: 'sensor_range_end' is required and must be non-zero",
+ path,
+ )
+ }
+
+ requiredDeps := []string{}
+ if cfg.Servo == "" {
+ return nil, nil, fmt.Errorf(
+ "%s: 'servo' (non-empty string) is required", path,
+ )
+ }
+ requiredDeps = append(requiredDeps, cfg.Servo)
+
+ if cfg.Sensor == "" {
+ return nil, nil, fmt.Errorf(
+ "%s: 'sensor' (non-empty string) is required", path,
+ )
+ }
+ requiredDeps = append(requiredDeps, cfg.Sensor)
+
+ return requiredDeps, []string{}, nil
+}
+```
+
+The `Validate` method returns two slices (required dependencies and optional
+dependencies) and an error.
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 4. Implement the constructor
+
+The constructor runs when the service is first created and again whenever its
+configuration changes. Use it to parse attributes and resolve dependencies.
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+@classmethod
+def new(
+ cls, config: ComponentConfig,
+ dependencies: Mapping[ResourceName, ResourceBase]
+) -> Self:
+ self = await super().new(config, dependencies)
+ attrs = struct_to_dict(config.attributes)
+
+ # Required attributes
+ self.sensor_range_start = float(attrs.get("sensor_range_start"))
+ self.sensor_range_end = float(attrs.get("sensor_range_end"))
+
+ # Optional attributes with defaults
+ self.servo_angle_min = float(attrs.get("servo_angle_min", 0))
+ self.servo_angle_max = float(attrs.get("servo_angle_max", 180))
+ self.reversed = attrs.get("reversed", False)
+
+ # Resolve dependencies
+ servo_name = attrs.get("servo")
+ sensor_name = attrs.get("sensor")
+ self.servo = dependencies[Servo.get_resource_name(servo_name)]
+ self.sensor = dependencies[Sensor.get_resource_name(sensor_name)]
+
+ return self
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func NewGenericService(
+ ctx context.Context, deps resource.Dependencies,
+ name resource.Name, conf *Config, logger logging.Logger,
+) (resource.Resource, error) {
+ cancelCtx, cancelFunc := context.WithCancel(context.Background())
+
+ // Apply defaults for optional fields
+ servoAngleMin := int64(0)
+ if conf.ServoAngleMin != nil {
+ servoAngleMin = *conf.ServoAngleMin
+ }
+ servoAngleMax := int64(180)
+ if conf.ServoAngleMax != nil {
+ servoAngleMax = *conf.ServoAngleMax
+ }
+ reversed := false
+ if conf.Reversed != nil {
+ reversed = *conf.Reversed
+ }
+
+ // Resolve dependencies
+ servoDep, err := servo.FromProvider(deps, conf.Servo)
+ if err != nil {
+ return nil, err
+ }
+ sensorDep, err := sensor.FromProvider(deps, conf.Sensor)
+ if err != nil {
+ return nil, err
+ }
+
+ return &genericService{
+ name: name,
+ logger: logger,
+ cfg: conf,
+ cancelCtx: cancelCtx,
+ cancelFunc: cancelFunc,
+ servo: servoDep,
+ sensor: sensorDep,
+ sensorRangeStart: conf.SensorRangeStart,
+ sensorRangeEnd: conf.SensorRangeEnd,
+ servoAngleMin: servoAngleMin,
+ servoAngleMax: servoAngleMax,
+ reversed: reversed,
+ }, nil
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+### 5. Implement DoCommand
+
+`DoCommand` is where your control logic goes. This example reads a distance
+sensor and maps the reading to a servo angle:
+
+{{< tabs >}}
+{{% tab name="Python" %}}
+
+```python
+async def do_command(
+ self, command: Mapping[str, ValueTypes], *,
+ timeout: Optional[float] = None, **kwargs
+) -> Mapping[str, ValueTypes]:
+ readings = await self.sensor.get_readings()
+ if not readings:
+ raise ValueError("No sensor readings available")
+
+ value = next(iter(readings.values()))
+
+ # Map sensor range to servo angle range
+ t = (value - self.sensor_range_start) / (
+ self.sensor_range_end - self.sensor_range_start
+ )
+ t = max(0.0, min(1.0, 1.0 - t if self.reversed else t))
+
+ angle = self.servo_angle_min + t * (
+ self.servo_angle_max - self.servo_angle_min
+ )
+ await self.servo.move(int(angle))
+
+ return {"servo_angle_deg": angle}
+```
+
+{{% /tab %}}
+{{% tab name="Go" %}}
+
+```go
+func (s *genericService) DoCommand(
+ ctx context.Context, cmd map[string]interface{},
+) (map[string]interface{}, error) {
+ readings, _ := s.sensor.Readings(ctx, nil)
+ value, ok := readings["distance"].(float64)
+ if !ok {
+ return nil, fmt.Errorf("sensor reading 'distance' must be a float64")
+ }
+
+ // Map sensor range to servo angle range
+ t := (value - s.sensorRangeStart) /
+ (s.sensorRangeEnd - s.sensorRangeStart)
+ if t < 0 { t = 0 } else if t > 1 { t = 1 }
+ if s.reversed { t = 1 - t }
+
+ angle := float64(s.servoAngleMin) +
+ t * (float64(s.servoAngleMax) - float64(s.servoAngleMin))
+ return map[string]interface{}{
+ "servo_angle_deg": angle,
+ }, s.servo.Move(ctx, uint32(angle), nil)
+}
+```
+
+{{% /tab %}}
+{{< /tabs >}}
+
+In this example no DoCommand payload is used. You can use the command payload to
+customize behavior per invocation. Attributes are constant across all
+invocations; the DoCommand payload can vary with each call.
+
+### 6. Save and deploy
+
+1. Click **Save & Deploy** in the code editor toolbar.
+2. Viam uploads your code as a new version and starts a cloud build.
+3. Builds typically take 2-5 minutes. You can continue editing while a build
+ runs -- your next save creates a new version.
+4. If the build fails, click **View Logs** to see what went wrong.
+
+Each save creates a new version in your module's history. You can switch between
+versions using the version dropdown in the editor toolbar.
+
+### 7. Test on a machine
+
+#### Add the module to a machine
+
+1. In the code editor, click **Add to machine**.
+2. Select a location, machine, and part.
+3. Click **Add**.
+
+The Viam app navigates you to the machine's **CONFIGURE** tab with your module
+added.
+
+#### Configure the service
+
+1. In the module section, click **Add** to add a model of your generic service.
+2. Click **+** to add each dependency your service requires (for example, a servo and
+ a sensor). Configure each dependency with the appropriate attributes.
+3. Configure the attributes for your generic service:
+
+```json
+{
+ "sensor_range_start": 0.05,
+ "sensor_range_end": 0.3,
+ "servo_angle_min": 40,
+ "servo_angle_max": 270,
+ "reversed": true,
+ "servo": "servo-1",
+ "sensor": "sensor-1"
+}
+```
+
+4. Click **Save**.
+
+#### Send test commands
+
+1. On the **CONFIGURE** tab, expand your generic service's **Test** section.
+2. Expand **DoCommand**.
+3. Enter a command (or an empty map `{}` if your DoCommand does not use the
+ payload):
+
+```json
+{}
+```
+
+4. Click **Execute**. You should see a response like:
+
+```json
+{ "servo_angle_deg": 155.0 }
+```
+
+### 8. Automate with a scheduled job
+
+The DoCommand section in the Viam app runs your logic once per click. To have
+it run automatically:
+
+1. Click **+** and select **Job**.
+2. Name the job and click **Create**.
+3. Choose a schedule. For control logic that should always run, select
+ **Continuous**.
+4. Select your generic service as the resource.
+5. Edit the DoCommand payload or leave it as an empty map if no payload is
+ needed.
+6. Click **Save**.
+
+## Try It
+
+1. Create a new inline module from the **+** menu.
+2. Implement `validate_config`, the constructor, and `do_command`.
+3. Click **Save & Deploy** and wait for the build to complete.
+4. Add the module to a machine and configure the service with dependencies.
+5. Execute a DoCommand and verify the response.
+6. Set up a scheduled job to run the logic continuously.
+
+## Troubleshooting
+
+{{< expand "\"Viam-hosted\" option not visible" >}}
+
+The inline module feature is gated by a feature flag. If you do not see the
+"Viam-hosted" option when clicking **+** → **Control code**, your organization
+may not have the feature enabled. Contact Viam support to request access.
+
+{{< /expand >}}
+
+{{< expand "Build fails after saving" >}}
+
+- Click **View Logs** in the build progress bar to see the error.
+- Common causes: syntax errors, missing imports, incompatible dependencies.
+- You can fix the code and save again -- each save creates a new version.
+
+{{< /expand >}}
+
+{{< expand "Module not appearing on machine" >}}
+
+- Verify the machine is online and connected to the cloud.
+- Check that you added the module using the **Add to machine** button in the
+ code editor, or that the module appears in the machine's configuration.
+- The module version defaults to `latest`. After a successful build, the machine
+ picks up the new version automatically within a few minutes.
+
+{{< /expand >}}
+
+{{< expand "DoCommand returns an error" >}}
+
+- Check that all dependencies are configured on the machine and are working.
+ Use the test section of each dependency to verify them in isolation.
+- Verify the attribute names in your service configuration match what
+ `validate_config` expects.
+- Check the **LOGS** tab for detailed error messages.
+
+{{< /expand >}}
+
+{{< expand "Changes to code not taking effect" >}}
+
+- Make sure the build completed successfully. Check the build progress bar in
+ the code editor.
+- The machine must be online to receive the updated module. Check the machine's
+ status in the Viam app.
+- By default, modules are configured with version `latest`, which means every
+ new build deploys automatically. If the version is pinned, update it manually.
+
+{{< /expand >}}
+
+## What's Next
+
+- [Write a Driver Module](/build-modules/write-a-driver-module/) -- build a module
+ with a typed resource API when you need to manage your own source code and
+ build pipeline.
+- [Write a Logic Module](/build-modules/write-a-logic-module/) -- build control
+ logic as an externally managed module with full IDE support.
+- [Deploy a Module](/build-modules/deploy-a-module/) -- package and upload
+ a module to the Viam registry for distribution to other machines or users.
diff --git a/docs/cli/_index.md b/docs/cli/_index.md
new file mode 100644
index 0000000000..3db90682ab
--- /dev/null
+++ b/docs/cli/_index.md
@@ -0,0 +1,12 @@
+---
+linkTitle: "Viam CLI"
+title: "Viam CLI"
+weight: 37
+layout: "docs"
+type: "docs"
+no_list: true
+manualLink: "/cli/overview/"
+description: "The Viam CLI gives you command-line access to every operation in the Viam platform, from machine configuration to data export to fleet management."
+aliases:
+ - /dev/tools/cli/
+---
diff --git a/docs/cli/administer-your-organization.md b/docs/cli/administer-your-organization.md
new file mode 100644
index 0000000000..b65773e1ee
--- /dev/null
+++ b/docs/cli/administer-your-organization.md
@@ -0,0 +1,201 @@
+---
+linkTitle: "Administer your organization"
+title: "Administer your organization with the CLI"
+weight: 70
+layout: "docs"
+type: "docs"
+description: "Manage API keys, OAuth, billing, and organization settings from the command line."
+---
+
+Create and manage API keys, configure OAuth authentication for end users, and set up white-label billing for your organization.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+{{< /expand >}}
+
+## Find your IDs
+
+To find your organization ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+To find location IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list
+```
+
+To find machine IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --all
+```
+
+## Manage locations
+
+List all locations in your organization:
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list
+```
+
+If you belong to multiple organizations, specify which one:
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list --organization=
+```
+
+If you have set a default organization with `viam defaults set-org`, the CLI uses it automatically.
+
+## Manage API keys
+
+### Organization API key
+
+Create an API key with organization-level access.
+Organization keys have the `organization_owner` role with full read and write access to every resource in the organization.
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations api-key create --org-id= --name=my-org-key
+```
+
+The CLI prints the key ID and key value. Save both immediately; the key value is only shown once.
+
+```sh {class="command-line" data-prompt="$" data-output="1-3"}
+Successfully created key:
+Key ID: abcdef12-3456-7890-abcd-ef1234567890
+Key Value: your-secret-key-value
+```
+
+### Location API key
+
+Create an API key scoped to a specific location.
+Location keys have the `location_owner` role.
+
+```sh {class="command-line" data-prompt="$"}
+viam locations api-key create --location-id= --name=my-location-key
+```
+
+### Machine API key
+
+Create an API key scoped to a single machine.
+Machine keys have the `robot_owner` role.
+
+```sh {class="command-line" data-prompt="$"}
+viam machines api-key create --machine-id=
+```
+
+## Set up OAuth
+
+OAuth setup is CLI-only. There is no web UI for these operations.
+
+### Enable the auth service
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service enable --org-id=
+```
+
+### Set branding
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations logo set --org-id= --logo-path=./logo.png
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations support-email set --org-id= --support-email=support@example.com
+```
+
+### Create an OAuth application
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service oauth-app create \
+ --org-id= \
+ --client-name=my-app \
+ --client-authentication=unspecified \
+ --url-validation=exact_match \
+ --pkce=required \
+ --enabled-grants=authorization_code \
+ --redirect-uris=https://example.com/callback \
+ --logout-uri=https://example.com/logout \
+ --origin-uris=https://example.com
+```
+
+On success, the CLI prints the client ID and client secret.
+Save both values immediately; you need the client ID for subsequent commands.
+
+### Manage OAuth applications
+
+List all OAuth applications:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service oauth-app list --org-id=
+```
+
+Get details on a specific application:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service oauth-app read \
+ --org-id= \
+ --client-id=
+```
+
+Update an application:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service oauth-app update \
+ --org-id= \
+ --client-id= \
+ --client-name=updated-name
+```
+
+Delete an application:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service oauth-app delete \
+ --org-id= \
+ --client-id=
+```
+
+### Disable the auth service
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations auth-service disable --org-id=
+```
+
+## Configure white-label billing
+
+Billing service setup is CLI-only.
+
+Enable billing.
+The address format is `"line1, line2 (optional), city, state, zipcode"`:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations billing-service enable --org-id= --address="123 Main St, Springfield, IL, 62704"
+```
+
+Get billing configuration:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations billing-service get-config --org-id=
+```
+
+Update billing:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations billing-service update --org-id= --address=
+```
+
+Disable billing:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations billing-service disable --org-id=
+```
+
+## Related pages
+
+- [Manage access](/organization/access/) for role-based access control in the Viam app
+- [Authenticate end users with OAuth](/organization/oauth/) for OAuth setup details
+- [White-labeled billing](/organization/billing/) for billing fragment configuration
+- [CLI reference](/cli/) for the complete `organizations` command reference
diff --git a/docs/cli/automate-with-scripts.md b/docs/cli/automate-with-scripts.md
new file mode 100644
index 0000000000..6f5a71e809
--- /dev/null
+++ b/docs/cli/automate-with-scripts.md
@@ -0,0 +1,211 @@
+---
+linkTitle: "Automate with scripts"
+title: "Automate with scripts"
+weight: 80
+layout: "docs"
+type: "docs"
+description: "Use the Viam CLI in shell scripts, CI/CD pipelines, and provisioning workflows."
+---
+
+Combine CLI commands into shell scripts, CI/CD pipelines, and provisioning workflows to automate common Viam operations.
+
+## Authenticate in scripts
+
+Interactive `viam login` opens a browser, which does not work in headless environments.
+Use API key authentication instead:
+
+```sh {class="command-line" data-prompt="$"}
+viam login api-key --key-id=$VIAM_API_KEY_ID --key=$VIAM_API_KEY
+```
+
+Store credentials in environment variables or your CI/CD system's secret manager, not in the script itself.
+To create an API key, see [Manage API keys](/cli/administer-your-organization/#manage-api-keys).
+
+### Use profiles for non-interactive auth
+
+If a script needs to operate across multiple organizations, set up profiles in advance:
+
+```sh {class="command-line" data-prompt="$"}
+viam profiles add --profile-name=production --key-id=$PROD_KEY_ID --key=$PROD_KEY
+viam profiles add --profile-name=staging --key-id=$STAGING_KEY_ID --key=$STAGING_KEY
+```
+
+Then use `--profile` on each command, or set `VIAM_CLI_PROFILE_NAME` to activate a profile for the entire script:
+
+```sh {class="command-line" data-prompt="$"}
+export VIAM_CLI_PROFILE_NAME=production
+viam machines list --all
+```
+
+## CI/CD: upload a module on release
+
+A GitHub Actions workflow that builds and uploads a module when you push a version tag:
+
+```yaml
+# .github/workflows/upload-module.yml
+name: Upload module
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ upload:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install Viam CLI
+ run: |
+ sudo curl --compressed -o /usr/local/bin/viam \
+ https://storage.googleapis.com/packages.viam.com/apps/viam-cli/viam-cli-stable-linux-amd64
+ sudo chmod a+rx /usr/local/bin/viam
+
+ - name: Authenticate
+ run: viam login api-key --key-id=${{ secrets.VIAM_KEY_ID }} --key=${{ secrets.VIAM_KEY }}
+
+ - name: Build
+ run: viam module build local
+
+ - name: Upload
+ run: |
+ VERSION=${GITHUB_REF_NAME#v}
+ viam module upload --version=$VERSION --platform=linux/amd64 dist/archive.tar.gz
+```
+
+## CI/CD: retrain a model on new data
+
+A script that creates a fresh dataset from recent data and submits a training job.
+
+Set these environment variables before running:
+
+- `VIAM_KEY_ID` and `VIAM_KEY`: your API key credentials (see [Manage API keys](/cli/administer-your-organization/#manage-api-keys))
+- `ORG_ID`: your organization ID (run `viam organizations list`)
+
+```sh
+#!/bin/bash
+set -euo pipefail
+
+viam login api-key --key-id=$VIAM_KEY_ID --key=$VIAM_KEY
+
+# Create a dataset from the last 7 days of labeled images.
+# The create command prints: "Created dataset with dataset ID: "
+DATASET_ID=$(viam dataset create --org-id=$ORG_ID --name="weekly-$(date +%F)" \
+ | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
+
+# date -v is macOS; date -d is Linux. Adjust for your platform.
+START=$(date -u -v-7d +%FT%TZ 2>/dev/null || date -u -d '7 days ago' +%FT%TZ)
+END=$(date -u +%FT%TZ)
+
+viam dataset data add filter \
+ --dataset-id=$DATASET_ID \
+ --org-ids=$ORG_ID \
+ --tags=labeled \
+ --start=$START \
+ --end=$END
+
+# Submit a managed training job
+viam train submit managed \
+ --dataset-id=$DATASET_ID \
+ --model-org-id=$ORG_ID \
+ --model-name=defect-detector-$(date +%F) \
+ --model-type=object_detection \
+ --model-framework=tflite \
+ --model-labels=defective,good
+```
+
+Schedule this as a cron job or a weekly CI/CD trigger.
+
+## Batch fleet operations
+
+### List all machines
+
+Set `ORG_ID` to your organization ID (run `viam organizations list`).
+
+```sh
+#!/bin/bash
+set -euo pipefail
+
+viam login api-key --key-id=$VIAM_KEY_ID --key=$VIAM_KEY
+
+# List all machines across all locations
+viam machines list --organization=$ORG_ID --all
+```
+
+## Provisioning: create and configure a machine
+
+Script that creates a machine and applies a standard configuration fragment.
+
+Set these environment variables before running:
+
+- `VIAM_KEY_ID` and `VIAM_KEY`: your API key credentials
+- `ORG_ID`: your organization ID
+- `LOCATION_ID`: your location ID (run `viam locations list`)
+
+The script takes two arguments: the machine name and the fragment ID.
+
+```sh
+#!/bin/bash
+set -euo pipefail
+
+MACHINE_NAME=$1
+FRAGMENT_ID=$2
+
+viam login api-key --key-id=$VIAM_KEY_ID --key=$VIAM_KEY
+
+# Create the machine.
+# Output format: "created new machine with id "
+CREATE_OUTPUT=$(viam machines create --name=$MACHINE_NAME --location=$LOCATION_ID)
+MACHINE_ID=$(echo "$CREATE_OUTPUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
+
+# Get the part ID from the part list.
+# Output includes lines like " ID: "
+PART_ID=$(viam machines part list --machine=$MACHINE_ID | grep 'ID:' | head -1 | awk '{print $2}')
+
+# Apply the configuration fragment
+viam machines part fragments add --part=$PART_ID --fragment=$FRAGMENT_ID
+
+echo "Machine $MACHINE_NAME ($MACHINE_ID) created with fragment $FRAGMENT_ID applied."
+```
+
+## Bulk data export
+
+Export all images from a fleet for offline analysis.
+Set `ORG_ID` to your organization ID.
+
+```sh
+#!/bin/bash
+set -euo pipefail
+
+viam login api-key --key-id=$VIAM_KEY_ID --key=$VIAM_KEY
+
+# date -v is macOS; date -d is Linux. Adjust for your platform.
+START=$(date -u -v-30d +%FT%TZ 2>/dev/null || date -u -d '30 days ago' +%FT%TZ)
+END=$(date -u +%FT%TZ)
+
+viam data export binary filter \
+ --destination=./fleet-data \
+ --org-ids=$ORG_ID \
+ --mime-types=image/jpeg,image/png \
+ --start=$START \
+ --end=$END \
+ --parallel=20
+```
+
+The `--parallel` flag controls how many concurrent downloads run (default: 100).
+Increase it for faster exports on high-bandwidth connections, or decrease it to reduce load.
+
+## Tips for scripting
+
+- Use `--quiet` (`-q`) to suppress non-essential output when parsing command results
+- Use `--debug` (`-vvv`) when troubleshooting a script
+- Set defaults with `viam defaults set-org` to avoid passing `--org-id` on every command
+- All timestamps use ISO-8601 format: `2026-01-15T00:00:00Z`
+- Exit codes are non-zero on failure, so `set -e` works as expected
+
+## Related pages
+
+- [Viam CLI overview](/cli/overview/) for installation and authentication
+- [Provision devices](/fleet/provision-devices/) for provisioning with viam-agent
+- [Deploy a module](/build-modules/deploy-a-module/) for GitHub Actions integration
+- [CLI reference](/cli/) for the complete command reference
diff --git a/docs/cli/build-and-deploy-modules.md b/docs/cli/build-and-deploy-modules.md
new file mode 100644
index 0000000000..cfb02c62b1
--- /dev/null
+++ b/docs/cli/build-and-deploy-modules.md
@@ -0,0 +1,244 @@
+---
+linkTitle: "Build and deploy modules"
+title: "Build and deploy modules with the CLI"
+weight: 50
+layout: "docs"
+type: "docs"
+description: "Scaffold, build, upload, and version modules from the command line."
+---
+
+Scaffold a new module, iterate on it locally with hot-reload, upload it to the registry, and manage versions and cloud builds.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+{{< /expand >}}
+
+## Find your IDs
+
+To find the part ID for a running machine (needed for reload and restart):
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --location=
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part list --machine=
+```
+
+To find your organization and location IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list
+```
+
+## Scaffold a new module
+
+Generate a module project with boilerplate code, a `meta.json` manifest, and a build script.
+This command does not require authentication, so you can scaffold a module before logging in.
+
+```sh {class="command-line" data-prompt="$"}
+viam module generate
+```
+
+The generator walks you through an interactive prompt to choose:
+
+- Module name
+- Programming language (Python or Go)
+- Namespace and visibility
+- Resource type (component or service) and API
+
+You can also pass flags to skip the interactive prompts:
+
+```sh {class="command-line" data-prompt="$"}
+viam module generate \
+ --name=my-sensor-module \
+ --language=python \
+ --visibility=public
+```
+
+## Iterate during development
+
+After making code changes, reload your module on a running machine without restarting the entire machine:
+
+```sh {class="command-line" data-prompt="$"}
+viam module reload-local --part-id=
+```
+
+To reload a module from the registry (after uploading a new version):
+
+```sh {class="command-line" data-prompt="$"}
+viam module reload --part-id=
+```
+
+If a reload is not sufficient, restart the module process:
+
+```sh {class="command-line" data-prompt="$"}
+viam module restart --part-id=
+```
+
+## Update model definitions
+
+After adding or changing models in your module, update the model definitions in `meta.json`.
+This command runs the module binary in a sandbox, queries it for the API-model pairs it advertises, and updates the manifest.
+It also auto-detects markdown documentation files named `namespace_module_model.md`.
+
+```sh {class="command-line" data-prompt="$"}
+viam module update-models --binary=./bin/module
+```
+
+Then push the updated `meta.json` to the registry:
+
+```sh {class="command-line" data-prompt="$"}
+viam module update
+```
+
+## Upload to the registry
+
+Upload a module version for a specific platform.
+The CLI validates the tarball before uploading: it checks for an executable at the declared entrypoint, verifies file permissions, and warns about platform mismatches or symlinks escaping the archive.
+Pass `--force` to skip validation.
+
+```sh {class="command-line" data-prompt="$"}
+viam module upload \
+ --version=1.0.0 \
+ --platform=linux/amd64 \
+ dist/archive.tar.gz
+```
+
+On success, the CLI prints a link to your module in the registry:
+
+```sh {class="command-line" data-prompt="$" data-output="1"}
+Version successfully uploaded! you can view your changes online here: https://app.viam.com/module/my-org/my-module
+```
+
+Upload for multiple platforms by running the command once per platform:
+
+```sh {class="command-line" data-prompt="$"}
+viam module upload --version=1.0.0 --platform=linux/amd64 dist/archive-amd64.tar.gz
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam module upload --version=1.0.0 --platform=linux/arm64 dist/archive-arm64.tar.gz
+```
+
+## Cloud builds
+
+For CI/CD workflows, use cloud builds to compile your module on Viam's build infrastructure.
+
+Start a cloud build:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build start --version=1.0.0
+```
+
+Build for multiple platforms in one command:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build start --version=1.0.0 --platforms=linux/amd64,linux/arm64
+```
+
+Build from a specific git ref:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build start --version=1.0.0 --ref=main
+```
+
+Build locally to test before pushing:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build local
+```
+
+List recent builds (the output includes build IDs you need for `build logs`):
+
+```sh {class="command-line" data-prompt="$"}
+viam module build list
+```
+
+View build logs:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build logs --build-id=
+```
+
+Wait for a build to complete and stream logs:
+
+```sh {class="command-line" data-prompt="$"}
+viam module build logs --build-id= --wait
+```
+
+## Download a module
+
+Download a module from the registry for local testing or inspection.
+The `--id` flag takes the format `org-namespace:module-name`:
+
+```sh {class="command-line" data-prompt="$"}
+viam module download \
+ --id=my-org:my-sensor-module \
+ --version=1.0.0 \
+ --platform=linux/amd64 \
+ --destination=./downloaded-module
+```
+
+## Create a module
+
+If you need to register a module in the registry before uploading (for example, to reserve a name), use `create`:
+
+```sh {class="command-line" data-prompt="$"}
+viam module create --name=my-new-module
+```
+
+Most users should use `viam module generate` instead, which handles both creation and scaffolding.
+
+## Convert xacro files to URDF
+
+If your module works with a robot described in [xacro](https://wiki.ros.org/xacro) format (the ROS XML macro language), convert it to URDF with the CLI.
+The conversion runs in a Docker container with the specified ROS distribution.
+
+```sh {class="command-line" data-prompt="$"}
+viam xacro convert \
+ --input-file=./robot.xacro \
+ --output-file=./robot.urdf
+```
+
+If the xacro file uses `` tags, pass the required arguments:
+
+```sh {class="command-line" data-prompt="$"}
+viam xacro convert \
+ --input-file=./robot.xacro \
+ --output-file=./robot.urdf \
+ --args name:=ur20
+```
+
+To collapse fixed joint chains (useful when the URDF must have a single end-effector):
+
+```sh {class="command-line" data-prompt="$"}
+viam xacro convert \
+ --input-file=./robot.xacro \
+ --output-file=./robot.urdf \
+ --collapse-fixed-joints
+```
+
+By default, the conversion uses the `osrf/ros:humble-desktop` Docker image.
+To use a different ROS distribution or a custom image:
+
+```sh {class="command-line" data-prompt="$"}
+viam xacro convert \
+ --input-file=./robot.xacro \
+ --output-file=./robot.urdf \
+ --docker-image=osrf/ros:jazzy-desktop
+```
+
+Use `--dry-run` to print the Docker command without running it.
+
+## Related pages
+
+- [Write a driver module](/build-modules/write-a-driver-module/) for a complete guide to writing a hardware driver
+- [Write a logic module](/build-modules/write-a-logic-module/) for writing automation and monitoring logic
+- [Deploy a module](/build-modules/deploy-a-module/) for deployment with GitHub Actions
+- [CLI reference](/cli/) for the complete `module` command reference
diff --git a/docs/cli/configure-machines.md b/docs/cli/configure-machines.md
new file mode 100644
index 0000000000..cc673b5aa8
--- /dev/null
+++ b/docs/cli/configure-machines.md
@@ -0,0 +1,219 @@
+---
+linkTitle: "Configure machines"
+title: "Configure machines with the CLI"
+weight: 10
+layout: "docs"
+type: "docs"
+description: "Create machines, add components and services, and manage fragments from the command line."
+---
+
+Create and configure machines, add and remove components and services, and apply configuration fragments from the command line.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+{{< /expand >}}
+
+## Find your IDs
+
+Many commands on this page require an organization ID, location ID, machine ID, or part ID.
+
+A machine can have one or more **parts**, each running a separate instance of `viam-server`.
+Most machines have a single part.
+Multi-part machines are used when one logical machine runs across multiple computers.
+Every machine has at least one part (the main part), which is created automatically when you create the machine.
+When you add resources, apply fragments, or restart, you target a specific part.
+
+Find your organization ID and location ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list
+```
+
+Find machine IDs and part IDs.
+The `machines list` command prints each machine's ID and its main part ID.
+Use `machines part list` to see all parts for a machine:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --location=
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part list --machine=
+```
+
+## Create a machine
+
+```sh {class="command-line" data-prompt="$"}
+viam machines create --name=my-machine --location=
+```
+
+This creates the machine in the Viam app.
+The machine is not connected to any hardware until you [install `viam-server`](/foundation/) on a device and configure it with this machine's credentials.
+
+On success, the CLI prints the new machine's ID:
+
+```sh {class="command-line" data-prompt="$" data-output="1"}
+created new machine with id abc12345-1234-abcd-5678-ef1234567890
+```
+
+Save this ID for subsequent commands.
+
+## List machines
+
+List all machines in a location:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --location=
+```
+
+List all machines across your entire organization:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --all
+```
+
+## Add a component or service
+
+Add a resource to a machine part using its model triplet:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part add-resource \
+ --part= \
+ --name=my-camera \
+ --model-name=viam:camera:webcam
+```
+
+This creates a bare resource entry with no additional attributes.
+Most components need attributes like pin numbers or device paths to function.
+See [Update resource attributes](#update-resource-attributes) to set them from the CLI, or configure them in the [Viam app](https://app.viam.com).
+
+The model triplet follows the format `namespace:module-name:model-name`.
+You can browse available models in the [Viam registry](https://app.viam.com/registry).
+Common model triplets:
+
+| Component | Model triplet |
+| -------------------- | ------------------------ |
+| Webcam camera | `viam:camera:webcam` |
+| GPIO motor | `viam:motor:gpio` |
+| Raspberry Pi 5 board | `viam:raspberry-pi:rpi5` |
+| Ultrasonic sensor | `viam:ultrasonic:sensor` |
+| Fake arm (testing) | `viam:arm:fake` |
+| Fake motor (testing) | `viam:motor:fake` |
+
+## Remove a component or service
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part remove-resource \
+ --part= \
+ --name=my-camera
+```
+
+Connected machines pick up configuration changes automatically.
+You do not need to restart the machine after adding or removing a resource.
+
+## Manage resources
+
+### Update resource attributes
+
+After adding a resource, use `viam resource update` to set its attributes.
+The `--config` flag accepts inline JSON or a path to a JSON file:
+
+```sh {class="command-line" data-prompt="$"}
+viam resource update --part= \
+ --resource-name=my-sensor --config '{"pin": "38"}'
+```
+
+To load attributes from a file:
+
+```sh {class="command-line" data-prompt="$"}
+viam resource update --part= \
+ --resource-name=my-sensor --config ./sensor-config.json
+```
+
+{{< alert title="Caution" color="caution" >}}
+The `--config` flag replaces all existing attributes on the resource.
+To modify a single attribute, include the full attribute set in your JSON.
+Passing an empty value for an attribute deletes it.
+{{< /alert >}}
+
+### Enable and disable resources
+
+Disable a resource to stop it without removing it from the configuration.
+This is useful for temporarily taking a component offline or debugging issues with a specific resource.
+
+```sh {class="command-line" data-prompt="$"}
+viam resource disable --part= --resource-name=my-sensor
+```
+
+Re-enable it:
+
+```sh {class="command-line" data-prompt="$"}
+viam resource enable --part= --resource-name=my-sensor
+```
+
+You can enable or disable multiple resources in a single command:
+
+```sh {class="command-line" data-prompt="$"}
+viam resource disable --part= \
+ --resource-name=my-sensor --resource-name=arm-1
+```
+
+## Apply a fragment
+
+Fragments are reusable configuration blocks that can define a set of components, services, and their attributes.
+Apply the same fragment across multiple machines to keep their configuration consistent.
+Add a fragment to a machine part by specifying its fragment ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part fragments add --part= --fragment=
+```
+
+If you omit the `--fragment` flag, the CLI prompts you to select a fragment interactively.
+
+Remove a fragment:
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part fragments remove --part= --fragment=
+```
+
+See [Reuse machine configuration](/fleet/reuse-configuration/) for details on creating and managing fragments.
+
+## Rename or move a machine
+
+```sh {class="command-line" data-prompt="$"}
+viam machines update \
+ --machine= \
+ --new-name=updated-name \
+ --new-location=
+```
+
+Moving a machine to a different location may affect access if your API keys or permissions are scoped to a specific location.
+
+## Delete a machine
+
+{{< alert title="Caution" color="caution" >}}
+Deleting a machine removes it and all of its configuration permanently.
+This cannot be undone.
+{{< /alert >}}
+
+```sh {class="command-line" data-prompt="$"}
+viam machines delete --machine=
+```
+
+## Restart a machine part
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part restart --part=
+```
+
+## Related pages
+
+- [Get started](/foundation/) for setting up your first machine
+- [Configure hardware](/hardware/) for component configuration with the Viam app
+- [Manage your fleet with the CLI](/cli/manage-your-fleet/) for monitoring and remote access
+- [CLI reference](/cli/) for the complete `machines` command reference
diff --git a/docs/cli/data-pipelines.md b/docs/cli/data-pipelines.md
new file mode 100644
index 0000000000..b6137b3615
--- /dev/null
+++ b/docs/cli/data-pipelines.md
@@ -0,0 +1,120 @@
+---
+linkTitle: "Data pipelines"
+title: "Data pipelines with the CLI"
+weight: 40
+layout: "docs"
+type: "docs"
+description: "Create and manage scheduled data pipelines from the command line."
+---
+
+Create data pipelines that run MQL aggregations on your captured data on a schedule, transforming raw sensor readings or image metadata into precomputed summaries you can query efficiently.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+
+You also need captured tabular data in the Viam cloud.
+See [Capture and sync data](/data/capture-sync/capture-and-sync-data/) to get started.
+{{< /expand >}}
+
+## Find your IDs
+
+To find your organization ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+To find pipeline IDs for existing pipelines:
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines list --org-id=
+```
+
+## Create a pipeline
+
+A pipeline needs a name, a cron schedule, an MQL query, and whether to backfill historical data:
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines create \
+ --org-id= \
+ --name=hourly-temp-avg \
+ --schedule="0 * * * *" \
+ --data-source-type=standard \
+ --mql='[{"$match":{"component_name":"my-sensor"}},{"$group":{"_id":"$part_id","avg_temp":{"$avg":"$data.readings.temperature"}}}]' \
+ --enable-backfill=false
+```
+
+On success, the CLI prints the pipeline name and ID:
+
+```sh {class="command-line" data-prompt="$" data-output="1"}
+hourly-temp-avg (ID: abcdef12-3456-7890-abcd-ef1234567890) created.
+```
+
+Save the pipeline ID for management commands.
+
+For complex queries, put the MQL in a JSON file:
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines create \
+ --org-id= \
+ --name=hourly-temp-avg \
+ --schedule="0 * * * *" \
+ --data-source-type=standard \
+ --mql-path=./pipeline-query.json \
+ --enable-backfill=false
+```
+
+| Flag | Required | Description |
+| ----------------------- | ------------ | ------------------------------------------------------------- |
+| `--name` | Yes | Pipeline name |
+| `--schedule` | Yes | Cron expression for when the pipeline runs |
+| `--enable-backfill` | Yes | `true` to run over historical data, `false` for new data only |
+| `--org-id` | No | Your organization ID (uses default if set) |
+| `--data-source-type` | No | `standard` (default) or `hotstorage` (hot data store) |
+| `--mql` or `--mql-path` | One required | MQL query as inline JSON or path to a JSON file |
+
+## List pipelines
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines list --org-id=
+```
+
+## Get pipeline details
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines describe --id=
+```
+
+## Enable and disable pipelines
+
+Disable a pipeline without deleting it:
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines disable --id=
+```
+
+Re-enable it:
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines enable --id=
+```
+
+## Rename a pipeline
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines rename --id= --name=new-pipeline-name
+```
+
+## Delete a pipeline
+
+```sh {class="command-line" data-prompt="$"}
+viam datapipelines delete --id=
+```
+
+## Related pages
+
+- [Create a data pipeline](/data/pipelines/create-a-pipeline/) for step-by-step pipeline creation with the Viam app and SDKs
+- [Pipeline examples and MQL tips](/data/pipelines/examples/) for common MQL patterns
+- [Query pipeline results](/data/pipelines/query-results/) for querying pipeline output
+- [CLI reference](/cli/) for the complete `datapipelines` command reference
diff --git a/docs/cli/datasets-and-training.md b/docs/cli/datasets-and-training.md
new file mode 100644
index 0000000000..0824d85a09
--- /dev/null
+++ b/docs/cli/datasets-and-training.md
@@ -0,0 +1,289 @@
+---
+linkTitle: "Datasets and training"
+title: "Datasets and training with the CLI"
+weight: 30
+layout: "docs"
+type: "docs"
+description: "Create datasets, manage training data, and submit ML training jobs from the command line."
+---
+
+Create and populate datasets, submit training jobs, manage training scripts, and run inference from the command line or in automation scripts.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+{{< /expand >}}
+
+## Find your IDs
+
+To find your organization ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+To list datasets and find dataset IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset list --org-id=
+```
+
+To list training containers and find valid container versions:
+
+```sh {class="command-line" data-prompt="$"}
+viam train containers list
+```
+
+## Manage datasets
+
+### Create a dataset
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset create --org-id= --name=my-dataset
+```
+
+The CLI prints the new dataset's name and ID:
+
+```sh {class="command-line" data-prompt="$" data-output="1"}
+Created dataset my-dataset with dataset ID: abcdef12-3456-7890-abcd-ef1234567890
+```
+
+Save the dataset ID for subsequent commands.
+
+### List datasets
+
+List all datasets in your organization:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset list --org-id=
+```
+
+List specific datasets by ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset list --dataset-ids=abc,def
+```
+
+### Add images to a dataset
+
+Adding images to a dataset creates references to the binary data items, it does not copy the data.
+Removing images removes the references without deleting the underlying data.
+
+Add images matching a filter (uses the same filter flags as [data export](/cli/manage-data/#export-images-and-binary-files)):
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset data add filter \
+ --dataset-id= \
+ --org-ids= \
+ --location-ids= \
+ --tags=defective \
+ --start=2026-01-01T00:00:00Z \
+ --end=2026-03-01T00:00:00Z
+```
+
+Add specific images by ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset data add ids \
+ --dataset-id= \
+ --binary-data-ids=aaa,bbb,ccc
+```
+
+### Remove images from a dataset
+
+By filter:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset data remove filter \
+ --dataset-id= \
+ --tags=low-quality
+```
+
+By ID:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset data remove ids \
+ --dataset-id= \
+ --binary-data-ids=aaa,bbb
+```
+
+### Export a dataset
+
+Download all data from a dataset to a local directory.
+The export includes the binary files plus a `dataset.jsonl` file with annotation metadata (classification labels, bounding boxes, timestamps, file paths) that Viam's training infrastructure consumes.
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset export \
+ --dataset-id= \
+ --destination=./my-dataset
+```
+
+To export only the metadata without downloading binary files (useful for inspecting annotations):
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset export \
+ --dataset-id= \
+ --destination=./my-dataset \
+ --only-jsonl
+```
+
+### Merge datasets
+
+Combine multiple datasets into a new one:
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset merge \
+ --name=combined-dataset \
+ --dataset-ids=abc,def,ghi
+```
+
+### Rename and delete datasets
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset rename --dataset-id= --name=new-name
+```
+
+Deleting a dataset removes the dataset and its references, but does not delete the underlying binary data.
+
+```sh {class="command-line" data-prompt="$"}
+viam dataset delete --dataset-id=
+```
+
+## Submit training jobs
+
+### Train with a built-in model type
+
+Submit a managed training job using one of Viam's built-in model types:
+
+```sh {class="command-line" data-prompt="$"}
+viam train submit managed \
+ --dataset-id= \
+ --model-org-id= \
+ --model-name=my-detector \
+ --model-type=single_label_classification \
+ --model-framework=tflite \
+ --model-labels=defective,good
+```
+
+On success, the CLI prints the job ID:
+
+```sh {class="command-line" data-prompt="$" data-output="1"}
+Submitted training job with ID abcdef12-3456-7890-abcd-ef1234567890
+```
+
+Model types: `single_label_classification`, `multi_label_classification`, `object_detection`.
+
+### Train with a custom training script
+
+Submit a job using a custom script from the registry:
+
+```sh {class="command-line" data-prompt="$"}
+viam train submit custom from-registry \
+ --dataset-id= \
+ --model-name=my-custom-model \
+ --org-id= \
+ --script-name= \
+ --version= \
+ --container-version=
+```
+
+Submit a custom script by uploading it directly:
+
+```sh {class="command-line" data-prompt="$"}
+viam train submit custom with-upload \
+ --dataset-id= \
+ --model-name=my-custom-model \
+ --model-org-id= \
+ --script-name=my-training-script \
+ --path=./my-training-script/ \
+ --framework=tflite \
+ --container-version=
+```
+
+### Monitor training jobs
+
+List training jobs in your organization:
+
+```sh {class="command-line" data-prompt="$"}
+viam train list --org-id=
+```
+
+Filter by status:
+
+```sh {class="command-line" data-prompt="$"}
+viam train list --org-id= --job-status=completed
+```
+
+Get details on a specific job (use the job ID from `train submit` or `train list`):
+
+```sh {class="command-line" data-prompt="$"}
+viam train get --job-id=
+```
+
+View training logs:
+
+```sh {class="command-line" data-prompt="$"}
+viam train logs --job-id=
+```
+
+Cancel a running job:
+
+```sh {class="command-line" data-prompt="$"}
+viam train cancel --job-id=
+```
+
+## Manage training scripts
+
+Upload a custom training script to the registry:
+
+```sh {class="command-line" data-prompt="$"}
+viam training-script upload \
+ --path=./my-training-script/ \
+ --script-name=my-training-script \
+ --framework=tflite
+```
+
+Update script visibility:
+
+```sh {class="command-line" data-prompt="$"}
+viam training-script update \
+ --script-name=my-training-script \
+ --visibility=public
+```
+
+Test a training script locally before uploading (use `viam train containers list` to find valid container versions).
+Local testing runs in a Docker container that mirrors the cloud training environment.
+The container validates that your script directory contains `setup.py` and `model/training.py`.
+
+{{< alert title="Note" color="note" >}}
+Local testing uses Google Vertex AI containers, which are linux/x86_64 only.
+On ARM Macs, Docker runs them through Rosetta emulation, which may be slower.
+{{< /alert >}}
+
+```sh {class="command-line" data-prompt="$"}
+viam training-script test-local \
+ --dataset-root=./my-dataset/ \
+ --training-script-directory=./my-training-script/ \
+ --container-version=
+```
+
+## Run inference
+
+Run inference on a single image using a trained model:
+
+```sh {class="command-line" data-prompt="$"}
+viam infer \
+ --binary-data-id= \
+ --model-org-id= \
+ --model-name=my-detector \
+ --model-version=latest
+```
+
+To find binary data IDs, export data with `viam data export` or browse the DATA page in the Viam app.
+
+## Related pages
+
+- [Create a dataset](/train/create-a-dataset/) for step-by-step dataset creation with the Viam app
+- [Train a model](/train/train-a-model/) for training with the Viam app
+- [Custom training scripts](/train/custom-training-scripts/) for writing custom training logic
+- [CLI reference](/cli/) for the complete `train` command reference
diff --git a/docs/cli/manage-data.md b/docs/cli/manage-data.md
new file mode 100644
index 0000000000..d9cffaa77a
--- /dev/null
+++ b/docs/cli/manage-data.md
@@ -0,0 +1,237 @@
+---
+linkTitle: "Manage data"
+title: "Manage data with the CLI"
+weight: 20
+layout: "docs"
+type: "docs"
+description: "Export, tag, delete, and query your machine data from the command line."
+---
+
+Export captured data to your local machine, organize it with tags, delete old data, and configure database access for direct queries.
+
+{{< expand "Prerequisites" >}}
+You need the Viam CLI installed and authenticated.
+See [Viam CLI overview](/cli/overview/) for installation and authentication instructions.
+{{< /expand >}}
+
+## Find your IDs
+
+Many data commands require an organization ID, location ID, or part ID.
+To look up these values:
+
+```sh {class="command-line" data-prompt="$"}
+viam organizations list
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam locations list
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam machines list --organization= --location=
+```
+
+```sh {class="command-line" data-prompt="$"}
+viam machines part list --machine=
+```
+
+## Export data
+
+### Export images and binary files
+
+Export all binary data from an organization:
+
+```sh {class="command-line" data-prompt="$"}
+viam data export binary filter \
+ --destination=./my-data \
+ --org-ids=
+```
+
+The CLI downloads files into the destination directory and prints progress as it goes.
+
+Narrow the export with filters:
+
+```sh {class="command-line" data-prompt="$"}
+viam data export binary filter \
+ --destination=./my-data \
+ --org-ids= \
+ --mime-types=image/jpeg,image/png \
+ --machine-id= \
+ --start=2026-01-01T00:00:00Z \
+ --end=2026-02-01T00:00:00Z
+```
+
+Available filters:
+
+| Filter | Flag | Example |
+| --------------------- | -------------------------------------- | ----------------------------------- |
+| By machine | `--machine-id` or `--machine-name` | `--machine-id=abc123` |
+| By part | `--part-id` or `--part-name` | `--part-id=def456` |
+| By location | `--location-ids` | `--location-ids=loc1,loc2` |
+| By time range | `--start`, `--end` | `--start=2026-01-01T00:00:00Z` |
+| By component | `--component-name`, `--component-type` | `--component-name=my-camera` |
+| By MIME type | `--mime-types` | `--mime-types=image/jpeg,image/png` |
+| By tag | `--tags` | `--tags=defective,reviewed` |
+| By bounding box label | `--bbox-labels` | `--bbox-labels=screw,bolt` |
+
+Export specific files by their binary data IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam data export binary ids \
+ --destination=./my-data \
+ --binary-data-ids=aaa,bbb,ccc
+```
+
+### Export sensor and tabular data
+
+Tabular exports require a part ID and resource identifier:
+
+```sh {class="command-line" data-prompt="$"}
+viam data export tabular \
+ --destination=./sensor-data \
+ --part-id= \
+ --resource-name=my-sensor \
+ --resource-subtype=rdk:component:sensor \
+ --method=Readings
+```
+
+Output is written to a `data.ndjson` file (one JSON object per line).
+You can also filter by time range with `--start` and `--end`.
+
+## Tag data
+
+Tags help you organize data for filtering, dataset creation, and search.
+
+### Add tags by ID
+
+Add tags to specific files by their binary data IDs:
+
+```sh {class="command-line" data-prompt="$"}
+viam data tag ids add \
+ --tags=reviewed,approved \
+ --binary-data-ids=aaa,bbb
+```
+
+Remove tags from specific files:
+
+```sh {class="command-line" data-prompt="$"}
+viam data tag ids remove \
+ --tags=reviewed \
+ --binary-data-ids=aaa,bbb
+```
+
+### Add tags by filter
+
+{{< alert title="Note" color="note" >}}
+The filter-based tag commands (`tag filter add` and `tag filter remove`) use deprecated underlying APIs.
+They still work but may be removed in a future release.
+Prefer the ID-based commands above when possible.
+{{< /alert >}}
+
+Add tags to all data matching a filter:
+
+```sh {class="command-line" data-prompt="$"}
+viam data tag filter add \
+ --tags=reviewed,approved \
+ --org-ids= \
+ --location-ids= \
+ --mime-types=image/jpeg
+```
+
+Remove tags by filter:
+
+```sh {class="command-line" data-prompt="$"}
+viam data tag filter remove \
+ --tags=reviewed \
+ --org-ids=