@unoverse-platform/studio 0.1.48 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/local/sample-nodes/samples/README.md +62 -0
  2. package/local/sample-nodes/samples/credentials/openAICredential.yaml +36 -0
  3. package/local/sample-nodes/samples/nodes/SampleChatStream/api/events.yaml +23 -0
  4. package/local/sample-nodes/samples/nodes/SampleChatStream/api/run.yaml +53 -0
  5. package/local/sample-nodes/samples/nodes/SampleChatStream/config.yaml +44 -0
  6. package/local/sample-nodes/samples/nodes/SampleChatStream/interface.yaml +29 -0
  7. package/local/sample-nodes/samples/nodes/SampleChatStream/node.yaml +39 -0
  8. package/local/sample-nodes/samples/nodes/SampleChatStream/test.yaml +21 -0
  9. package/local/sample-nodes/samples/nodes/SampleWeather/api/events.yaml +41 -0
  10. package/local/sample-nodes/samples/nodes/SampleWeather/api/run.yaml +51 -0
  11. package/local/sample-nodes/samples/nodes/SampleWeather/config.yaml +39 -0
  12. package/local/sample-nodes/samples/nodes/SampleWeather/interface.yaml +24 -0
  13. package/local/sample-nodes/samples/nodes/SampleWeather/node.yaml +56 -0
  14. package/local/sample-nodes/samples/nodes/SampleWeather/test.yaml +24 -0
  15. package/local/sample-nodes/samples/package.yaml +41 -0
  16. package/local/sample-nodes/samples/shared/helpers.yaml +27 -0
  17. package/local/scaffold.mjs +10 -0
  18. package/package.json +1 -1
  19. package/screens/AtomsView.tsx +14 -4
  20. package/screens/ComponentsView.tsx +16 -3
  21. package/screens/DesignSystemView.tsx +1 -1
  22. package/src/App.tsx +1 -1
  23. package/vendor/skills/unoverse-create/SKILL.md +9 -7
  24. package/vendor/skills/unoverse-create/references/brand.md +6 -3
  25. package/vendor/skills/unoverse-create/references/component.md +8 -7
  26. package/vendor/skills/unoverse-create/references/node.md +6 -21
  27. package/vendor/skills/unoverse-create/references/template.md +4 -3
  28. package/vendor/skills/unoverse-create/references/workflow.md +6 -0
  29. package/vendor/skills/unoverse-prompt-component/SKILL.md +2 -3
@@ -0,0 +1,62 @@
1
+ # Sample nodes
2
+
3
+ Two worked examples of the Unoverse node format, one of each kind. They are real,
4
+ working nodes: wire them into a workflow and they run.
5
+
6
+ | Node | Kind | What it teaches |
7
+ |---|---|---|
8
+ | `nodes/SampleWeather` | `PromiseNode` | Two chained calls, a config form, computed outputs. No API key. |
9
+ | `nodes/SampleChatStream` | `CallbackNode` | Streaming, and how credentials work. Needs an OpenAI key. |
10
+
11
+ ## Where to start
12
+
13
+ Read `SampleWeather` first, file by file, in this order:
14
+
15
+ 1. `node.yaml` says what the node IS: its identity, its kind, and when a planner
16
+ should pick it.
17
+ 2. `interface.yaml` says what it CONNECTS TO: inputs, outputs. Output names become
18
+ what someone types in a template later, so they are named for what they carry.
19
+ 3. `config.yaml` is the settings form a builder sees on the canvas. It is a JSON
20
+ schema; the platform draws the form from it.
21
+ 4. `api/run.yaml` is the calls the node makes, always a list, each entry named.
22
+ 5. `api/events.yaml` is everything that leaves the node: one row per output.
23
+ 6. `test.yaml` is a fixture that runs against the real service.
24
+
25
+ Then read `SampleChatStream` for the two things weather cannot show you: a reply
26
+ that arrives as a stream of events rather than one body, and a call that has to
27
+ prove itself to the service with a credential.
28
+
29
+ ## Running them
30
+
31
+ Open Studio (`unoverse studio`) and go to the Nodes screen. Pick a node, load its
32
+ sample, and run it. Running is the only proof: a node that lints but has never run
33
+ is not done.
34
+
35
+ `SampleWeather` runs as-is. `SampleChatStream` needs a key: credentials are read
36
+ from your `.env` as `<CREDENTIAL>_<FIELD>` in upper snake case, so
37
+ `openAICredential.apiKey` is:
38
+
39
+ ```
40
+ OPENAI_API_KEY=sk-...
41
+ ```
42
+
43
+ The credential FILE in this package (`credentials/openAICredential.yaml`) declares
44
+ only the shape. No value ever lives in a node file, which is why the folder is safe
45
+ to commit and to share.
46
+
47
+ ## Making your own
48
+
49
+ Copy the closer of the two samples into a new folder under `nodes/<your-package>/`
50
+ and change it one file at a time, running as you go. The guide behind every file:
51
+
52
+ - The format in full: https://docs.unoverse.ai/nodes/manifest-nodes
53
+ - One answer or many: https://docs.unoverse.ai/nodes/node-types
54
+ - Credentials: https://docs.unoverse.ai/nodes/credentials
55
+ - The settings form: https://docs.unoverse.ai/nodes/config-schema
56
+ - Paging, batching, polling: https://docs.unoverse.ai/nodes/calls-that-loop
57
+ - Getting picked by the planner: https://docs.unoverse.ai/nodes/node-discoverability
58
+ - Testing: https://docs.unoverse.ai/nodes/testing-nodes
59
+ - When something goes wrong: https://docs.unoverse.ai/nodes/troubleshooting
60
+
61
+ The condensed rulebook, written for coding agents and just as useful to people:
62
+ https://docs.unoverse.ai/nodes/CLAUDE.md
@@ -0,0 +1,36 @@
1
+ # THE SHAPE OF A CREDENTIAL, NEVER ITS VALUE.
2
+ #
3
+ # This file declares what an OpenAI credential LOOKS LIKE: which fields it has, which
4
+ # are secret, what to show in the form that collects it. It never holds a key. Values
5
+ # live where they already live: encrypted in the database on a deployed universe, and
6
+ # in your own .env when running locally.
7
+ #
8
+ # Locally the runner reads .env as <CREDENTIAL>_<FIELD> in upper snake case, so
9
+ # openAICredential.apiKey is the .env line:
10
+ #
11
+ # OPENAI_API_KEY=sk-...
12
+ #
13
+ # Nodes reference this shape BY NAME. At run time every credential in the workflow is
14
+ # available keyed by name, and a call selects the one it means:
15
+ #
16
+ # credential:
17
+ # scheme: bearer
18
+ # token: "{{ credentials.openAICredential.apiKey }}"
19
+ #
20
+ # Full guide: https://docs.unoverse.ai/nodes/credentials
21
+
22
+ name: openAICredential
23
+ displayName: OpenAI
24
+ description: Credentials for the OpenAI API
25
+ documentationUrl: https://platform.openai.com/api-keys
26
+
27
+ properties:
28
+ - name: apiKey
29
+ displayName: API Key
30
+ type: string
31
+ required: true
32
+ # `secret: true` is what keeps the value write-only: it is stored encrypted and
33
+ # never echoed back into any form or log.
34
+ secret: true
35
+ description: Your OpenAI API key
36
+ placeholder: sk-...
@@ -0,0 +1,23 @@
1
+ # ONE ROW PER OUTPUT, in interface.yaml order. On a streaming call each row names the
2
+ # event type it fires on with `match`, and there are two rows here because a streaming
3
+ # node answers twice: continuously while writing, and once when done.
4
+ # https://docs.unoverse.ai/nodes/node-types
5
+
6
+ # THE LIVE FEED. Two settings here are worth copying into any streaming node you
7
+ # write. `accumulate: true` emits the reply SO FAR rather than the newest fragment: a
8
+ # stream of single words is almost never what a consumer wants. `throttleMs` bounds
9
+ # how often it emits, or a long answer becomes hundreds of events; nothing held back
10
+ # is lost, whatever is pending is flushed when the run ends.
11
+ - emit: stream
12
+ from: response
13
+ match: response.output_text.delta
14
+ value: "return response.delta"
15
+ accumulate: true
16
+ throttleMs: 200
17
+
18
+ # THE SETTLED ANSWER. `from: complete` fires once at the end, over everything the
19
+ # node emitted, which is how a streaming node also produces one finished value.
20
+ # `.at(-1)` and not `.pop()`: expressions are sandboxed and nothing mutates.
21
+ - emit: text
22
+ from: complete
23
+ value: "return events.filter(e => e.emit === 'stream').map(e => e.value).at(-1) ?? ''"
@@ -0,0 +1,53 @@
1
+ # ONE CALL, MANY ANSWERS. `transport: sse` says the reply arrives as a stream of
2
+ # events rather than one body, which is what makes this node a CallbackNode.
3
+ # https://docs.unoverse.ai/nodes/node-types
4
+
5
+ - name: generate
6
+ method: POST
7
+ url: https://api.openai.com/v1/responses
8
+
9
+ # HOW THE NODE PROVES ITSELF TO THE VENDOR. This is OUTBOUND authentication, and it
10
+ # is a different thing from node.yaml's `auth`, which is who may RUN the node.
11
+ #
12
+ # `scheme: bearer` is an executor capability: the executor attaches the header, so
13
+ # no manifest ever handles the secret itself. The token path selects the credential
14
+ # BY NAME from the shapes this package declares. Note also that api.openai.com is
15
+ # in package.yaml's allowedHosts; a manifest cannot send this token anywhere that
16
+ # list does not name. https://docs.unoverse.ai/nodes/credentials
17
+ credential:
18
+ scheme: bearer
19
+ token: "{{ credentials.openAICredential.apiKey }}"
20
+
21
+ body:
22
+ model: "{{ config.model }}"
23
+ # A REQUEST WHOSE SHAPE CHANGES WITH THE SETTINGS. The system message exists only
24
+ # when the builder wrote one: sending an empty message is a 400 from the vendor,
25
+ # so the body is built by an expression rather than a fixed template. A field
26
+ # that is one "{{ path }}" stays a template; a field that must DECIDE something
27
+ # is an expression.
28
+ input: >-
29
+ return [
30
+ config.systemPrompt
31
+ ? { type: "message", role: "system", content: config.systemPrompt }
32
+ : null,
33
+ { type: "message", role: "user", content: config.prompt }
34
+ ].filter(Boolean)
35
+ max_output_tokens: "{{ config.maxTokens }}"
36
+ stream: true
37
+
38
+ timeoutMs: 300000
39
+
40
+ retry:
41
+ attempts: 3
42
+ backoff: exponential
43
+ on: [429, 500, 502, 503, 504]
44
+
45
+ # The reply's framing belongs to the call: it streams server-sent events until the
46
+ # terminator line.
47
+ transport: sse
48
+ terminator: "[DONE]"
49
+
50
+ # A 200 carrying an error body must not read as success.
51
+ error:
52
+ when: "return response.type === 'response.failed'"
53
+ message: "return response.response.error.message"
@@ -0,0 +1,44 @@
1
+ # THE SETTINGS FORM. https://docs.unoverse.ai/nodes/config-schema
2
+
3
+ configSchema:
4
+ type: object
5
+ required: [model, prompt]
6
+
7
+ properties:
8
+ model:
9
+ type: string
10
+ title: Model
11
+ description: The model that writes the reply
12
+ enum: [gpt-5.6, gpt-5.6-terra, gpt-5.6-luna]
13
+ default: gpt-5.6-terra
14
+ "ui:widget": select
15
+
16
+ prompt:
17
+ type: string
18
+ title: Prompt
19
+ description: >-
20
+ What to ask. A template field, so values from earlier nodes compose in
21
+ directly, e.g. Summarize this update. {{signal.weather1.summary}}
22
+ default: ""
23
+ ui:field: template
24
+
25
+ systemPrompt:
26
+ type: string
27
+ title: System Prompt
28
+ description: >-
29
+ Standing instructions for every run: the model's role, tone and limits.
30
+ Optional. NEVER paste instruction text into a manifest file itself; shared
31
+ instruction copy belongs in the prompt block library and is referenced here
32
+ as {{prompt.<blockName>}}, so it keeps tracking the block when the block is
33
+ edited.
34
+ default: ""
35
+ ui:field: template
36
+
37
+ maxTokens:
38
+ type: number
39
+ title: Max output tokens
40
+ description: The ceiling on how long the reply may run
41
+ default: 1024
42
+ minimum: 16
43
+ maximum: 32768
44
+ "ui:order": [model, prompt, systemPrompt, maxTokens]
@@ -0,0 +1,29 @@
1
+ # WHAT THIS NODE CONNECTS TO. Two outputs is the signature streaming pair: the live
2
+ # feed for whoever wants to watch, and the settled answer for whoever wants the
3
+ # result. Wire `stream` to a streaming text component and the reply appears as it is
4
+ # written; wire `text` onward and downstream nodes get one finished string.
5
+
6
+ inputs:
7
+ - name: signal
8
+ type: object
9
+ description: Data from previous nodes that can be referenced in templates
10
+
11
+ outputs:
12
+ - name: stream
13
+ type: object
14
+ description: The reply so far, emitted repeatedly while the model writes
15
+
16
+ - name: text
17
+ type: string
18
+ description: The complete reply, emitted once at the end
19
+
20
+ # THE CREDENTIAL THIS NODE NEEDS, referenced by NAME. The shape it points at is
21
+ # credentials/openAICredential.yaml in this package: fields, which are secret, what
22
+ # the collecting form shows. The VALUE is never in any file. On a universe it is
23
+ # entered once and stored encrypted; locally it is read from .env as
24
+ # OPENAI_API_KEY. https://docs.unoverse.ai/nodes/credentials
25
+ credentials:
26
+ - name: openAICredential
27
+ required: true
28
+ displayName: OpenAI API
29
+ description: How this node authenticates to OpenAI
@@ -0,0 +1,39 @@
1
+ # WHAT THIS NODE IS: the second worked example, a CallbackNode.
2
+ #
3
+ # Read SampleWeather first. This node adds the two things weather cannot show you:
4
+ # a reply that STREAMS in as many events instead of settling once, and a call that
5
+ # must prove itself to the service with a CREDENTIAL.
6
+ # https://docs.unoverse.ai/nodes/node-types
7
+
8
+ type: SampleChatStream
9
+
10
+ # A node that keeps answering. Lint checks this rather than trusting it: the LAST
11
+ # call in api/run.yaml has `transport: sse`, so declaring PromiseNode here would be
12
+ # an error naming exactly that contradiction.
13
+ kind: CallbackNode
14
+
15
+ name: Chat Stream Sample
16
+ category: AI
17
+ color: "#7C5CFF"
18
+
19
+ description: A model reply streamed word by word as it is written, the simplest streaming shape
20
+
21
+ # Ranked against the user's own words, so: outcome first, disqualifiers as
22
+ # properties of the work, wiring fact last, and never the name of another node.
23
+ # https://docs.unoverse.ai/nodes/node-discoverability
24
+ whenToUse: >-
25
+ A worked example for learning the streaming node shape: send a prompt, watch the
26
+ reply arrive word by word, and read the manifest that made it happen. A single
27
+ pass with no tools and no memory, so real workloads that need tool calls,
28
+ reasoning control or multi-turn state want a fuller model node.
29
+
30
+ # WHO MAY RUN THIS. Compulsory, and separate from the credential below: this block
31
+ # is who may press the button, the credential is how the node proves itself to the
32
+ # vendor once pressed. https://docs.unoverse.ai/nodes/who-can-run-it
33
+ auth:
34
+ required: false
35
+
36
+ capabilities:
37
+ # A model completion is non-deterministic: re-running is correct behaviour, and
38
+ # reusing a previous answer would be wrong, not thrifty.
39
+ cacheable: false
@@ -0,0 +1,21 @@
1
+ # THE PROOF. This fixture calls the real API, so it needs your key in .env first:
2
+ #
3
+ # OPENAI_API_KEY=sk-...
4
+ #
5
+ # (The name comes from the credential shape: openAICredential.apiKey, upper snake
6
+ # case, so OPENAI_API_KEY.) Then run it from Studio's Nodes screen, or:
7
+ # unoverse node test SampleChatStream
8
+ #
9
+ # Assert the shape, never exact words: the model will not say the same thing twice.
10
+ # https://docs.unoverse.ai/nodes/testing-nodes
11
+
12
+ testData:
13
+ config:
14
+ model: gpt-5.6-luna
15
+ prompt: Say hello in one short sentence.
16
+ systemPrompt: ""
17
+ maxTokens: 64
18
+ inputs:
19
+ signal: {}
20
+ expect:
21
+ text: "return output.text.length > 0"
@@ -0,0 +1,41 @@
1
+ # EVERYTHING THAT LEAVES THIS NODE: one row per output connector, in the SAME ORDER
2
+ # interface.yaml declares them. Lint holds the order and the coverage: an output with
3
+ # no row here stays silently empty, which is the first thing to check when a
4
+ # downstream template resolves to nothing.
5
+ #
6
+ # In these expressions `response` is the LAST call's reply (the forecast) and every
7
+ # call is also readable by name as `calls.<name>`. The sandbox allows no mutation:
8
+ # `.at(-1)` not `.pop()`, `.toSorted()` not `.sort()`.
9
+ # https://docs.unoverse.ai/nodes/signal-routing
10
+
11
+ # The full picture, shaped HERE so the component that shows it stays dumb. Anything
12
+ # computed (rounding, pairing the daily arrays up, turning code 61 into "light rain")
13
+ # is this node's job: definitions in design/ never compute.
14
+ - emit: weather
15
+ value: >-
16
+ return {
17
+ place: calls.geocode.results[0].name,
18
+ country: calls.geocode.results[0].country || "",
19
+ unit: response.current_units.temperature_2m,
20
+ current: {
21
+ temperature: Math.round(response.current.temperature_2m),
22
+ feelsLike: Math.round(response.current.apparent_temperature),
23
+ humidity: response.current.relative_humidity_2m,
24
+ windSpeed: response.current.wind_speed_10m,
25
+ condition: helpers.describe(response.current.weather_code)
26
+ },
27
+ daily: response.daily.time.map((date, i) => ({
28
+ date: date,
29
+ high: Math.round(response.daily.temperature_2m_max[i]),
30
+ low: Math.round(response.daily.temperature_2m_min[i]),
31
+ condition: helpers.describe(response.daily.weather_code[i])
32
+ }))
33
+ }
34
+
35
+ # The one-liner, for dropping straight into a sentence or a headline. Same facts,
36
+ # second shape: emitting both is cheaper than making every consumer re-derive one.
37
+ - emit: summary
38
+ value: >-
39
+ return Math.round(response.current.temperature_2m) + "° and " +
40
+ helpers.describe(response.current.weather_code) + " in " +
41
+ calls.geocode.results[0].name
@@ -0,0 +1,51 @@
1
+ # THE CALLS THIS NODE MAKES. Always a list, each entry named, even for one call.
2
+ #
3
+ # This node is the "two calls, where the second uses the first" shape: a person types
4
+ # a city NAME, the forecast API wants COORDINATES, so the node looks the place up and
5
+ # then asks about it. A later call reads an earlier reply as `calls.<name>`. The node
6
+ # orchestrates both behind one clean answer; the workflow never sees the seam.
7
+ # https://docs.unoverse.ai/nodes/manifest-nodes
8
+
9
+ # CALL 1: the place, by name. Open-Meteo's geocoder, no key needed.
10
+ - name: geocode
11
+ method: GET
12
+ url: https://geocoding-api.open-meteo.com/v1/search
13
+ # `transport: json` says the reply arrives as ONE body. The streaming sample next
14
+ # door uses `sse`, where the reply arrives as many events.
15
+ transport: json
16
+ query:
17
+ name: "{{ config.city }}"
18
+ count: "1"
19
+
20
+ # ALWAYS write `error` where the API can answer 200 with a failure body. An unknown
21
+ # place is a 200 with no results, and without this rule it would flow downstream as
22
+ # an empty forecast instead of failing here with the true reason.
23
+ error:
24
+ when: "return !(response.results && response.results.length)"
25
+ message: >-
26
+ return 'No place named "' + config.city + '" was found. Check the spelling, or
27
+ try a larger town nearby.'
28
+
29
+ # CALL 2: the forecast, at the coordinates call 1 found. Dot paths into an earlier
30
+ # reply, and array indexes are dot segments too: `results.0.latitude`, never brackets.
31
+ - name: forecast
32
+ method: GET
33
+ url: https://api.open-meteo.com/v1/forecast
34
+ transport: json
35
+ query:
36
+ latitude: "{{ calls.geocode.results.0.latitude }}"
37
+ longitude: "{{ calls.geocode.results.0.longitude }}"
38
+ current: temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code
39
+ daily: temperature_2m_max,temperature_2m_min,weather_code
40
+ forecast_days: "{{ config.days }}"
41
+ temperature_unit: "{{ config.units }}"
42
+ timezone: auto
43
+
44
+ timeoutMs: 30000
45
+
46
+ # Retry only what retrying can fix: rate limits and transient server errors. A 404
47
+ # or a bad request will fail identically every time, so it is not in the list.
48
+ retry:
49
+ attempts: 3
50
+ backoff: exponential
51
+ on: [429, 500, 502, 503, 504]
@@ -0,0 +1,39 @@
1
+ # THE SETTINGS FORM. This is a JSON schema; the platform draws the canvas form
2
+ # from it. A `description` says what the setting DOES, not what it is called.
3
+ # Every field type, and the run context that resolves them:
4
+ # https://docs.unoverse.ai/nodes/config-schema
5
+
6
+ configSchema:
7
+ type: object
8
+ required: [city]
9
+
10
+ properties:
11
+ city:
12
+ type: string
13
+ title: City
14
+ description: >-
15
+ The place to report on. A city or town name, in any language. This is a
16
+ template field, so a value from an earlier node works here too, e.g.
17
+ {{signal.inputtrigger1.output.city}}
18
+ default: ""
19
+ # `ui:field: template` is what lets a workflow supply the value at run time
20
+ # instead of it being fixed at build time. Every value the workflow supplies
21
+ # needs it.
22
+ ui:field: template
23
+
24
+ days:
25
+ type: number
26
+ title: Forecast days
27
+ description: How many days of outlook to fetch, today included
28
+ default: 3
29
+ minimum: 1
30
+ maximum: 7
31
+
32
+ units:
33
+ type: string
34
+ title: Units
35
+ description: The temperature scale everything is reported in
36
+ enum: [celsius, fahrenheit]
37
+ default: celsius
38
+ "ui:widget": select
39
+ "ui:order": [city, days, units]
@@ -0,0 +1,24 @@
1
+ # WHAT THIS NODE CONNECTS TO: the sockets on the box a builder sees on the canvas.
2
+ #
3
+ # Output names become what someone types in a template later
4
+ # ("{{signal.weather1.summary}}"), so they are named for what they CARRY.
5
+ # https://docs.unoverse.ai/nodes/signal-routing
6
+
7
+ inputs:
8
+ - name: signal
9
+ type: object
10
+ description: Data from previous nodes that can be referenced in templates
11
+
12
+ outputs:
13
+ # The full picture, for binding into a component field by field.
14
+ - name: weather
15
+ type: object
16
+ description: Place, current conditions and the daily outlook, as one object
17
+
18
+ # The one-line version, for dropping straight into text.
19
+ - name: summary
20
+ type: string
21
+ description: A single sentence, like "18° and partly cloudy in Lisbon"
22
+
23
+ # No credentials block: Open-Meteo asks for no key. See SampleChatStream for a node
24
+ # that needs one.
@@ -0,0 +1,56 @@
1
+ # WHAT THIS NODE IS. The one required file.
2
+ #
3
+ # A worked example of a PromiseNode: it makes its calls, settles once, and hands on
4
+ # one answer. Read this folder in order: node.yaml (identity), interface.yaml
5
+ # (connections), config.yaml (the settings form), api/run.yaml (the calls),
6
+ # api/events.yaml (what leaves), test.yaml (the proof).
7
+ # Format in full: https://docs.unoverse.ai/nodes/manifest-nodes
8
+
9
+ # The IDENTITY a saved workflow stores. Renaming it orphans every workflow that
10
+ # uses the node, so never rename lightly.
11
+ type: SampleWeather
12
+
13
+ # One answer or many? A node that calls, settles and hands on ONE reply is a
14
+ # PromiseNode. Lint checks this against the transport of the LAST call in
15
+ # api/run.yaml (json settles; sse or ws streams).
16
+ # https://docs.unoverse.ai/nodes/node-types
17
+ kind: PromiseNode
18
+
19
+ name: Weather Sample
20
+ # The JOB, not the implementation, and from the platform's closed list: lint
21
+ # refuses an invented category, because the canvas palette groups by these.
22
+ category: Storage & Data
23
+ color: "#4A90D9"
24
+
25
+ description: Current conditions and a short forecast for any city, from Open-Meteo, no API key needed
26
+
27
+ # WHENTOUSE DECIDES WHETHER THIS NODE IS EVER OFFERED. A planner ranks nodes by
28
+ # embedding this text against the user's own words: outcome first, then what
29
+ # disqualifies it as a property of the work, never the name of another node.
30
+ # Read https://docs.unoverse.ai/nodes/node-discoverability before writing yours.
31
+ whenToUse: >-
32
+ Get the weather for a named place: current temperature, wind, humidity and a
33
+ day-by-day outlook, ready to show to a person or hand to a model. Takes a city or
34
+ town name, not coordinates. It reports observed forecasts only; it cannot answer
35
+ historical climate questions.
36
+
37
+ # WHO MAY RUN THIS. Compulsory on every node. `required: false` adds no requirement
38
+ # of its own: the node runs for whoever the workflow's trigger admitted. It does NOT
39
+ # mean public; the trigger still owns the door.
40
+ # https://docs.unoverse.ai/nodes/who-can-run-it
41
+ auth:
42
+ required: false
43
+ # Role-based access is one line, and naming a role implies required: true. But a
44
+ # role HERE is only for a claim the platform itself defines (marketplace:publish),
45
+ # because a published node cannot know what roles a stranger's identity provider
46
+ # mints. A business role like finance:approve belongs to the workflow builder: the
47
+ # platform puts a "Require sign-in" / "Require role" section on every node's
48
+ # settings form, so the person who knows their deployment's role names sets one
49
+ # per box. You never author those fields.
50
+ # role: marketplace:publish
51
+
52
+ capabilities:
53
+ # A forecast changes hour to hour, so re-running is the correct behaviour and a
54
+ # cached answer would be stale, not thrifty. `cacheable: true` is ONLY for
55
+ # idempotent, side-effect-free reads of stable data.
56
+ cacheable: false
@@ -0,0 +1,24 @@
1
+ # A FIXTURE THAT RUNS, against the real service. Every node needs one: a node that
2
+ # lints but has never run is not done, because only a run proves the manifest
3
+ # describes the real API rather than something merely coherent.
4
+ #
5
+ # Run it from Studio's Nodes screen (Load sample, then Run), or:
6
+ # unoverse node test SampleWeather
7
+ #
8
+ # Assert the SHAPE, never exact words: the temperature in Lisbon is not a constant.
9
+ # https://docs.unoverse.ai/nodes/testing-nodes
10
+
11
+ testData:
12
+ config:
13
+ city: Lisbon
14
+ days: 3
15
+ units: celsius
16
+ inputs:
17
+ signal: {}
18
+ expect:
19
+ weather: >-
20
+ return output.weather.place === "Lisbon" &&
21
+ typeof output.weather.current.temperature === "number" &&
22
+ typeof output.weather.current.condition === "string" &&
23
+ output.weather.daily.length === 3
24
+ summary: "return output.summary.length > 0 && output.summary.includes('Lisbon')"
@@ -0,0 +1,41 @@
1
+ # THE PACKAGE ENVELOPE. Every node package has exactly one of these.
2
+ #
3
+ # A node is a folder of YAML that the platform interprets. There is no TypeScript,
4
+ # no package.json, no build step and no publish step. The full format:
5
+ # https://docs.unoverse.ai/nodes/package-marketplace
6
+ #
7
+ # This package holds two worked examples, one of each node kind:
8
+ #
9
+ # nodes/SampleWeather/ a PromiseNode. Two chained calls, one settled answer.
10
+ # nodes/SampleChatStream/ a CallbackNode. A reply that streams in as it is written.
11
+ #
12
+ # Read them in that order. Then read the guide they follow:
13
+ # https://docs.unoverse.ai/nodes/overview
14
+
15
+ name: samples
16
+ displayName: Sample Nodes
17
+ description: Two worked examples for learning the node format, one PromiseNode and one CallbackNode
18
+ version: 0.1.0
19
+ category: productivity
20
+
21
+ features:
22
+ - Weather for any city, no API key needed
23
+ - Streaming chat completion, showing how credentials work
24
+
25
+ # THE ONLY HOSTS THIS PACKAGE'S NODES MAY CALL. Deny by default, https only.
26
+ #
27
+ # A manifest cannot execute code, but without this list it could still say "send this
28
+ # credential to evil.example", which is exfiltration with no code at all. One short
29
+ # reviewable list is what makes a node package safe to accept from someone else.
30
+ # The executor refuses any request to a host not named here.
31
+ allowedHosts:
32
+ - geocoding-api.open-meteo.com
33
+ - api.open-meteo.com
34
+ - api.openai.com
35
+
36
+ # EXECUTOR CAPABILITIES these manifests depend on. A deploy is refused if the target
37
+ # universe's executor cannot satisfy them, so a manifest can never name a capability
38
+ # that only exists on a newer executor.
39
+ requires:
40
+ credential: [bearer]
41
+ transport: [json, sse]
@@ -0,0 +1,27 @@
1
+ # NAMED HELPERS. Any expression in this package can call these as helpers.<name>.
2
+ #
3
+ # An expression past a few lines gets a name here instead. A helper sees ONLY its
4
+ # arguments and its sibling helpers, never config or credentials or the caller's
5
+ # scope: same sandbox, no extra authority.
6
+ # https://docs.unoverse.ai/nodes/manifest-nodes
7
+
8
+ helpers:
9
+ # WMO weather interpretation codes, as words. Open-Meteo answers with a numeric
10
+ # `weather_code`; a person (and a component) wants "light rain". This mapping is
11
+ # LOGIC, and logic lives in the node: a definition in design/ must never compute,
12
+ # so the node sends words and the component just shows them.
13
+ describe:
14
+ args: [code]
15
+ body: >-
16
+ return ({
17
+ 0: "clear skies", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
18
+ 45: "fog", 48: "freezing fog",
19
+ 51: "light drizzle", 53: "drizzle", 55: "heavy drizzle",
20
+ 56: "freezing drizzle", 57: "heavy freezing drizzle",
21
+ 61: "light rain", 63: "rain", 65: "heavy rain",
22
+ 66: "freezing rain", 67: "heavy freezing rain",
23
+ 71: "light snow", 73: "snow", 75: "heavy snow", 77: "snow grains",
24
+ 80: "light showers", 81: "showers", 82: "heavy showers",
25
+ 85: "snow showers", 86: "heavy snow showers",
26
+ 95: "a thunderstorm", 96: "a thunderstorm with hail", 99: "a thunderstorm with heavy hail"
27
+ })[code] || "changeable weather"
@@ -84,6 +84,16 @@ export function createFirstProject(home, name) {
84
84
  const root = createProject(home, name);
85
85
  for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
86
86
 
87
+ // Two worked example nodes (sample-nodes/samples: a PromiseNode and a CallbackNode,
88
+ // heavily commented, linking docs.unoverse.ai/nodes), so `nodes/` is never an empty
89
+ // folder with no hint of what goes in it. Copied only when absent: these are the
90
+ // developer's files to edit or delete, and a later scaffold run must never put back
91
+ // what they threw away or overwrite what they changed.
92
+ const samples = join(dirname(fileURLToPath(import.meta.url)), "sample-nodes/samples");
93
+ if (existsSync(samples) && !existsSync(join(home, "nodes/samples"))) {
94
+ cpSync(samples, join(home, "nodes/samples"), { recursive: true });
95
+ }
96
+
87
97
  // The Claude Code authoring skills, so "open the folder and describe what you want"
88
98
  // works in every Studio project from its first minute. Vendored into the published
89
99
  // app (vendor/skills, written by prepack from the monorepo's .claude/skills, the one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
@@ -127,17 +127,27 @@ export function AtomsView({ theme }: { theme: ResolvedTheme }) {
127
127
  const uri = useMemo(() => (selected ? `unoverse://atoms/${slug(selected)}` : ""), [selected]);
128
128
 
129
129
  return (
130
- <div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
130
+ <div className="grid min-h-0 flex-1 grid-cols-[240px_1fr] overflow-hidden">
131
131
  {/* NAVIGATOR */}
132
132
  <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#1b2129] p-2">
133
133
  {atoms.length === 0 && (
134
134
  <div className="p-2 text-xs text-gray-400">No atoms — restart the Unoverse server so /dev/atoms is available.</div>
135
135
  )}
136
+ {/* Atoms are ALL base tier (the one shared home): say so once at the top,
137
+ matching the Components sidebar's Base divider. */}
138
+ {atoms.length > 0 && (
139
+ <div className="mb-1 px-2 pt-1 text-[10px] font-semibold uppercase tracking-wider text-gray-300">
140
+ Base <span className="font-normal normal-case tracking-normal text-gray-500">· installed, read-only</span>
141
+ </div>
142
+ )}
136
143
  {groups.map((g) => (
137
144
  <div key={g.label} className="mb-2">
138
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">
139
- {g.label}
140
- <span className="ml-1.5 font-normal text-gray-500">{g.atoms.length}</span>
145
+ <div className="mb-1 mt-1 flex items-center gap-2 px-2">
146
+ <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-gray-400">
147
+ {g.label}
148
+ <span className="ml-1.5 font-normal lowercase tracking-normal text-gray-500">{g.atoms.length}</span>
149
+ </span>
150
+ <span className="h-px flex-1 bg-gradient-to-r from-gray-500/40 to-transparent" />
141
151
  </div>
142
152
  {g.atoms.map((a) => (
143
153
  <button
@@ -368,15 +368,21 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
368
368
  store.apply({ type: "COMPONENT_DATA", chatId: CHAT_ID, nodeId: selected, data: { [key]: val } });
369
369
  };
370
370
 
371
+ // ONE catalog, grouped by CATEGORY — a project's card variants belong beside the
372
+ // base cards, where someone looking for "a card" will look. OWNERSHIP is a per-item
373
+ // mark, not a section: the org tag says "yours, editable"; untagged means base
374
+ // (installed, read-only — the Code view's badge carries the detail). Within a
375
+ // category the developer's own components list first.
371
376
  const byCategory = Object.entries(
372
377
  components.reduce<Record<string, ComponentDef[]>>((acc, c) => {
373
378
  (acc[c.category ?? "Uncategorized"] ??= []).push(c);
374
379
  return acc;
375
380
  }, {}),
376
381
  ).sort(([a], [b]) => a.localeCompare(b));
382
+ for (const [, items] of byCategory) items.sort((a, b) => (a.org ? 0 : 1) - (b.org ? 0 : 1));
377
383
 
378
384
  return (
379
- <div className="grid min-h-0 flex-1 grid-cols-[190px_1fr] overflow-hidden">
385
+ <div className="grid min-h-0 flex-1 grid-cols-[240px_1fr] overflow-hidden">
380
386
  {/* NAVIGATOR */}
381
387
  <nav className="flex min-h-0 min-w-0 flex-col overflow-auto border-r border-gray-700 bg-[#2c2c2e] p-2">
382
388
  {components.length === 0 && (
@@ -384,7 +390,12 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
384
390
  )}
385
391
  {byCategory.map(([cat, items]) => (
386
392
  <div key={cat} className="mb-3">
387
- <div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400">{cat}</div>
393
+ {/* A hairline that fades right lifts the group header without adding weight —
394
+ wider tracking + lighter weight reads as a label, not another item. */}
395
+ <div className="mb-1 mt-1 flex items-center gap-2 px-2">
396
+ <span className="text-[10px] font-medium uppercase tracking-[0.14em] text-gray-400">{cat}</span>
397
+ <span className="h-px flex-1 bg-gradient-to-r from-gray-500/40 to-transparent" />
398
+ </div>
388
399
  {items.map((c) => (
389
400
  <div key={refOf(c)}>
390
401
  <button
@@ -394,7 +405,9 @@ export function ComponentsView({ theme, org = "all" }: { theme: ResolvedTheme; o
394
405
  }`}
395
406
  >
396
407
  {c.name}
397
- {c.org ? <span className="ml-1.5 text-[10px] text-gray-500">{c.org}</span> : null}
408
+ {/* Ownership mark: tagged = the developer's own (editable); untagged =
409
+ base, installed and read-only. Emerald so "mine" reads at a glance. */}
410
+ {c.org ? <span className="ml-1.5 text-[10px] text-emerald-400/80">{c.org}</span> : null}
398
411
  </button>
399
412
  {/* The component's OWN states (its states/ folder) live HERE in the rail.
400
413
  The two MASTER states (inline/focused — the `defaultState` face switch,
@@ -279,7 +279,7 @@ export function DesignSystemView({
279
279
  <div className="flex h-full" style={{ background: theme.color["surface.sunken"] ?? "#f9fafb", color: ink }}>
280
280
  {/* LEFT NAV — jump between token groups; badge = how many tokens THIS project overrides */}
281
281
  <nav className="w-56 shrink-0 overflow-auto border-r p-5" style={{ borderColor: borderC, background: surface, fontFamily: bodyFont }}>
282
- <h1 className="mb-1 text-lg font-bold">Design System</h1>
282
+ <h1 className="mb-1 text-lg font-bold">Styles</h1>
283
283
  <p className="mb-5 text-[11px] leading-snug" style={{ color: sub }}>
284
284
  {foundation ? (
285
285
  <>Viewing <b style={{ color: ink }}>{org}</b></>
package/src/App.tsx CHANGED
@@ -33,7 +33,7 @@ const NAV: { key: View; label: string; group: string }[] = [
33
33
  { key: "apps", label: "Apps", group: "Build" },
34
34
  { key: "components", label: "Components", group: "Build" },
35
35
  { key: "atoms", label: "Atoms", group: "Build" },
36
- { key: "tokens", label: "Design System", group: "Build" },
36
+ { key: "tokens", label: "Styles", group: "Build" },
37
37
  { key: "skills", label: "Skills", group: "AI" },
38
38
  { key: "prompts", label: "Prompt Blocks", group: "AI" },
39
39
  { key: "nodes", label: "Nodes", group: "Code" },
@@ -23,13 +23,15 @@ that is the workspace folder itself; in the platform monorepo it is `apps/unover
23
23
  "org"). If the workspace has more than one, or the only one looks like a placeholder,
24
24
  ask which project the artifact belongs to. Never invent a new project folder to hold
25
25
  one artifact.
26
- 2. **The design system is installed, not on disk.** A developer workspace holds only the
27
- developer's own projects; the marketplace design system (shared atoms, components,
28
- the token foundation) resolves from the installed platform. See it through Studio's
29
- authoring MCP: `listDefinitions` (kind `atom` / `component`, org `marketplace`),
30
- `readDefinition` for a full definition, `getVocabulary` for the closed node/style
31
- vocabulary. Compose those atoms; never hand-roll a shape the design system already
32
- ships. (In the platform monorepo the same material is on disk at `design/marketplace/`.)
26
+ 2. **The base design system is public. Read it before you write.** A developer
27
+ workspace holds only the developer's own projects; the base components, atoms, and
28
+ the token foundation the platform installs are all published at
29
+ [marketplace/definitions](https://github.com/unoverse-platform/marketplace/tree/main/definitions)
30
+ ([components](https://github.com/unoverse-platform/marketplace/tree/main/definitions/components), [atoms](https://github.com/unoverse-platform/marketplace/tree/main/definitions/atoms), [styles](https://github.com/unoverse-platform/marketplace/tree/main/definitions/styles)).
31
+ Study the closest exemplar there, then compose those atoms and reference those
32
+ components; never hand-roll a shape the base already ships. Everything is plain
33
+ YAML files — no connection, no running platform needed. (In the platform monorepo
34
+ the same material is on disk at `design/marketplace/`.)
33
35
 
34
36
  ## Step 1: Identify the artifact, read its playbook
35
37
 
@@ -87,9 +87,12 @@ Its theme is then served at `unoverse://theme/<neworg>/light`, and a template po
87
87
 
88
88
  ## 4. Anatomy of an org pack (`design/<org>/styles/`)
89
89
 
90
- Read an installed org pack as the worked example (Studio MCP `listOrgs` →
91
- `listDefinitions`; in the platform monorepo, `design/bpp/styles/`). Every file in a healthy
92
- pack carries a `$description` explaining its own job, and those descriptions are the spec.
90
+ The foundation's token contract is public — read
91
+ [marketplace/definitions/styles](https://github.com/unoverse-platform/marketplace/tree/main/definitions/styles) (`base/`, `semantic/`, `themes/`); its
92
+ `themes/light.yaml` is the file §3 tells you to copy as your starting theme. (A worked
93
+ client pack lives in the platform monorepo at `design/bpp/styles/`.) Every file in a
94
+ healthy pack carries a `$description` explaining its own job, and those descriptions are
95
+ the spec.
93
96
 
94
97
  | File | Holds | Rule |
95
98
  |---|---|---|
@@ -6,10 +6,11 @@ model). Deep law: [UNOVERSE_AUTHORING](https://docs.unoverse.ai/unoverse/UNOVERS
6
6
  [UNOVERSE_STATE_MODEL](https://docs.unoverse.ai/unoverse/UNOVERSE_STATE_MODEL.md) §5 (the six rules),
7
7
  [UNOVERSE_LAYERS](https://docs.unoverse.ai/unoverse/UNOVERSE_LAYERS.md) (states own layouts).
8
8
  **Study an exemplar before writing.** The canonical State Model v2 state tree is inlined
9
- below; for a full worked component, read an installed one through Studio's MCP
10
- (`listDefinitions` kind `component`, then `readDefinition`), and prefer a rich
11
- multi-state one over a starter stub. (In the platform monorepo the canonical exemplar
12
- is on disk: `design/sab/components/product-card`.)
9
+ below. For full worked components, read the published base set at
10
+ [marketplace/definitions/components](https://github.com/unoverse-platform/marketplace/tree/main/definitions/components) — flat single-drawing components
11
+ throughout, and [document](https://github.com/unoverse-platform/marketplace/tree/main/definitions/components/document) for the manifest + local-partials
12
+ folder shape. (In the platform monorepo the richest multi-state exemplar is on disk:
13
+ `design/sab/components/product-card`.)
13
14
 
14
15
  ## The anatomy
15
16
 
@@ -141,8 +142,8 @@ The rules, in the order they bite:
141
142
  of bound fields). Same shape, different values = ONE state, data-driven: seven wizard
142
143
  questions sharing one shape are one `question` state whose data changes, never seven
143
144
  files (LAYERS §4, §7).
144
- 5. **Atoms** (the installed design system's shared shapes, used via `Ref`; list them with
145
- Studio's MCP `listDefinitions` kind `atom`, or on disk at `design/marketplace/atoms/` in the
145
+ 5. **Atoms** (the base design system's shared shapes, used via `Ref`; browse them at
146
+ [marketplace/definitions/atoms](https://github.com/unoverse-platform/marketplace/tree/main/definitions/atoms), or on disk at `design/marketplace/atoms/` in the
146
147
  platform monorepo) are for shapes shared across components, authoring-time
147
148
  only (the server expands them before serving; no Studio view). `Ref props` remaps FIELDS;
148
149
  **`Ref with` passes LITERALS**: `{ "type": "Ref", "ref": "button", "with": { "label":
@@ -270,7 +271,7 @@ component. Its key nouns/verbs must appear in `whenToUse`'s first sentence.
270
271
 
271
272
  ## Workflow
272
273
 
273
- 1. Study the closest installed exemplar (Studio MCP `listDefinitions` → `readDefinition`;
274
+ 1. Study the closest published exemplar ([marketplace/definitions/components](https://github.com/unoverse-platform/marketplace/tree/main/definitions/components);
274
275
  monorepo: `design/sab/components/product-card`); copy its folder shape.
275
276
  2. Declare the state tree first: public states on top, steps nested, each state owning
276
277
  its layout (same-name default, `layout:` only when the filename differs).
@@ -89,27 +89,12 @@ package only for a new integration.
89
89
  3. **Write `interface.yaml`.** Name outputs for what they carry, because those names become
90
90
  what someone types in a template.
91
91
  4. **Write `config.yaml`.** Every value the workflow supplies needs `ui:field: template`. A
92
- `description` says what the setting does, not what it is called. Then add the two
93
- run-authorization fields, which every node carries identically:
94
-
95
- ```yaml
96
- authRequired:
97
- type: boolean
98
- title: Require sign-in
99
- default: false
100
- "ui:widget": toggle
101
- authRole:
102
- type: string
103
- title: Require role
104
- default: ""
105
- "ui:dependencies": { authRequired: true }
106
- ```
107
-
108
- These are the workflow BUILDER's control, per box on a canvas, separate from
109
- `node.yaml`'s `auth`, which is your floor. The executor takes the stricter of the two. A
110
- role usually belongs here rather than in the manifest: `finance:approve` is a claim one
111
- deployment's identity provider mints, and a published node cannot know it. Add both to
112
- `ui:order`.
92
+ `description` says what the setting does, not what it is called. NEVER declare
93
+ `authRequired` or `authRole`: they are reserved names, injected by the platform into
94
+ every runnable node's form as the workflow builder's per-box access controls, and lint
95
+ refuses a manifest carrying its own copy. Your floor stays in `node.yaml`'s `auth`; the
96
+ executor takes the stricter of the two. A node with no settings at all needs no
97
+ config.yaml.
113
98
  5. **Write `api/run.yaml`.** A list, each entry named. Include `error` wherever the service
114
99
  can answer 200 with a failure body. Where one call is really many, name the capability on
115
100
  the call rather than trying to express a loop: `paginate`, `chunk`, `poll` or `state`.
@@ -6,9 +6,10 @@
6
6
  [UNOVERSE_STATE_MODEL](https://docs.unoverse.ai/unoverse/UNOVERSE_STATE_MODEL.md) §5
7
7
  (the six rules), [UNOVERSE_LAYERS](https://docs.unoverse.ai/unoverse/UNOVERSE_LAYERS.md).
8
8
  Everything in the component playbook (closed vocabulary, tokens, scale steps) applies
9
- here too. **Study an exemplar before writing**: read the closest installed template
10
- through Studio's MCP (`listDefinitions` kind `template` → `readDefinition`); the
11
- canonical manifest tree is inlined below. (Monorepo: `design/sab/templates/sab-chat-layout`.)
9
+ here too. **Study an exemplar before writing**: templates are always a project's own
10
+ (none ship in the base set), so your references are the canonical manifest tree inlined
11
+ below, [design/templates](https://docs.unoverse.ai/design/templates.md), and any template
12
+ already in the workspace. (Monorepo: `design/sab/templates/sab-chat-layout`.)
12
13
 
13
14
  ## THE model: the component drives; the template reacts by name
14
15
 
@@ -1,5 +1,11 @@
1
1
  # Playbook: Build a workflow (via the builder MCP)
2
2
 
3
+ **A DIFFERENT JOURNEY.** Everything else in this skill is file authoring in the
4
+ developer's own workspace: no connection, no running platform, no pairing code.
5
+ Workflows alone are built on a RUNNING universe's Canvas through the builder MCP, and
6
+ only here does a pairing code exist. Do not carry this playbook's ceremony into any
7
+ other artifact.
8
+
3
9
  Workflows are NOT files in the carve-out. They live in the platform and are built
4
10
  through the **builder MCP** (`canvas`, auto-registered by this repo's
5
11
  `.mcp.json`). You drive the platform's own authoring tools; the canvas updates live
@@ -32,9 +32,8 @@ It prints every part the agent can fill, its description, and its limits. Read t
32
32
  output, not the YAML. Then run the design lint
33
33
  (`node -e "import('./packages/base/src/lint/design/index.mjs').then(m=>{const r=m.lintDefinitions();console.log(r.problems.length);r.problems.forEach(p=>console.log(p.level,p.file,p.msg))})"`).
34
34
 
35
- In a **Studio developer workspace**, the same linter runs as Studio's publish lint (or
36
- the authoring MCP's `lintProject` tool); it catches two of the five traps below by
37
- name. Read what it says rather than silencing it, and always do the final proof in
35
+ In a **Studio developer workspace**, the same linter runs as Studio's publish lint; it
36
+ catches two of the five traps below by name. Read what it says rather than silencing it, and always do the final proof in
38
37
  Step 6: one real fill.
39
38
 
40
39
  ---