lantern

how-to-create-oculus-tool

How to Create an Oculus Tool

TL;DR: Write a Python fence with a label β†’ It becomes a callable MCP tool.


The Pattern

  • Create a fence with a label: ```python[my-tool-name]
  • Use config to receive parameters
  • Set result to return data
  • Document inline so the tool is self-describing

That's it. The fence IS the tool.


Live Example: Magic 8-Ball

This fence demonstrates the pattern. It's a working tool you can call right now.

config

tool

{
  "question": "Will things work out?",
  "answer": "Signs point to yes.",
  "confidence": 0.6,
  "advice": "Promising signs",
  "oracle": "🎱"
}

How It Works

The Label Makes It Callable

```python[magic-8-ball]    ← This label registers it as a tool
...code...
```                        ← Tool is now callable as "magic-8-ball"

Params Become Config

When you call the tool:

oculus_execute_fence({
  fence_id: "magic-8-ball",
  params: { question: "Should I refactor?" }  // ← These become 'config'
})

Inside the fence, access via:

question = config.get('question', 'default value')

Result Is Your Return Value

Whatever you assign to result gets returned:

result = {"answer": "Yes!", "data": [...]}

Try It Now

question: Is this tutorial helpful?

❌ Fence Execution Error: No fence found in section 'tool' of node 'how-to-create-oculus-tool'


Creating Your Own Tool

Step 1: Create the Node

# Via CLI
oculus create my-cool-tool

# Via MCP
oculus_create_node({ slug: "my-cool-tool", content: "# My Cool Tool\n\n## tool\n\n```python[my-cool-tool]\nresult = {'hello': 'world'}\n```" })

Step 2: Write the Fence

# Label your fence: ```python[my-cool-tool]
"""
Brief description of what this tool does.

USAGE:
  oculus_execute_fence({ fence_id: "my-cool-tool", params: {...} })

PARAMS:
  param1 (type): Description
  param2 (type): Description [optional, default: x]

RETURNS:
  { field1: ..., field2: ... }
"""

# Read params
param1 = config.get('param1', 'default')
param2 = config.get('param2', 'default')

# Do work
output = do_something(param1, param2)

# Return result
result = {
    "status": "success",
    "data": output
}

Step 3: Trigger Index Rebuild

The fence index updates automatically on poke, but you can force it:

# Just poke any section to trigger re-index
oculus_poke({ slug: "my-cool-tool", path: "config", value: "", context: "trigger re-index" })

Step 4: Call Your Tool

oculus_execute_fence({
  fence_id: "my-cool-tool",
  params: { param1: "value1" },
  context: "Testing my new tool"
})

Self-Documentation Pattern

The best tools document themselves. Include a docstring at the top:

# Label: ```python[my-tool]
"""
πŸ”§ Tool Name

One-line description.

USAGE:
  oculus_execute_fence({ fence_id: "my-tool", params: {...} })

PARAMS:
  name (string): What it's for
  count (int): How many [optional, default: 10]

RETURNS:
  { result: ..., metadata: ... }

EXAMPLE:
  { name: "test", count: 5 } β†’ { result: "processed 5 items" }
"""

This docstring appears in:

  • The fence metadata (oculus_get_fence)
  • The node content (human-readable)
  • Error messages (when params are wrong)

Addressing Formats

Your tool can be called multiple ways:

Format Example When to Use
Label magic-8-ball Simple, global (must be unique)
slug:label how-to-create-oculus-tool:magic-8-ball Explicit, avoids conflicts
slug:index how-to-create-oculus-tool:1 When you know the position

Quick Reference

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  FENCE TOOL ANATOMY                                         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚  ```python[tool-name]     ← Label = tool identifier        β”‚
β”‚  """                                                        β”‚
β”‚  Docstring = self-documentation                             β”‚
β”‚  """                                                        β”‚
β”‚                                                             β”‚
β”‚  # Read params                                              β”‚
β”‚  x = config.get('param', 'default')   ← params β†’ config    β”‚
β”‚                                                             β”‚
β”‚  # Do work                                                  β”‚
β”‚  output = process(x)                                        β”‚
β”‚                                                             β”‚
β”‚  # Return                                                   β”‚
β”‚  result = {"data": output}            ← result β†’ response  β”‚
β”‚  ```                                                        β”‚
β”‚                                                             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Working with Data in Fences

When your tool needs to read/write data stored in Oculus nodes, you have two options: native graph functions (preferred) or subprocess calls.

Native Graph Functions (Recommended)

Python fences have direct access to the graph through built-in functions. No imports needed!

Available Functions
Function Signature Description
config dict Parameters passed to the fence
peek peek(slug, path, format='raw') Read value from graph
poke poke(slug, path, value, operation='set', fence_type=None) Write value to graph
execute execute(fence_id, params=None, force=False) Execute another fence
get_node_content get_node_content(slug) Get raw markdown of a node
context FenceExecutionContext Execution context object
peek(slug, path, format='raw')

Read values directly from the graph:

# Read a YAML value
location = peek("adventure-game", "data.yaml.location")

# Read entire section
config = peek("my-node", "config.yaml")

# Returns None if not found
value = peek("node", "missing.path")  # β†’ None
poke(slug, path, value, operation='set', fence_type=None)

Write values directly to the graph:

# Set a value
poke("adventure-game", "data.yaml.location", "cave-dark")

# Append to a list
poke("my-node", "log.yaml.entries", new_entry, operation='append')

# Write with fence wrapper
poke("my-node", "config", {"key": "value"}, fence_type='yaml')
execute(fence_id, params=None, force=False)

Call other fences from within a fence:

# Execute by label
result = execute("magic-8-ball", params={"question": "Will it work?"})

# Execute by slug:label
data = execute("my-node:my-tool", params={"count": 5})

# Force execute even if execute bit is false
output = execute("disabled-fence", force=True)
get_node_content(slug)

Fetch raw markdown content:

# Get a node's content
content = get_node_content("my-documentation")

# Extract title
for line in content.split('\n'):
    if line.startswith('# '):
        title = line[2:].strip()
        break
Example: Reading List Tool

Here's a complete example using native functions:

"""
πŸ“ Add to Reading List

PARAMS:
  topic (string): The reading list topic name
  slug (string): The node slug to add
"""
topic = config.get('topic')
slug_to_add = config.get('slug')

# Native peek - direct graph access!
current_list = peek("required-reading", f"readings.yaml.{topic}") or []

if slug_to_add not in current_list:
    current_list.append(slug_to_add)
    # Native poke - direct graph access!
    poke("required-reading", f"readings.yaml.{topic}", current_list)

result = {"success": True, "count": len(current_list)}

Subprocess Method (Legacy)

For cases where native functions aren't available, use CLI subprocess calls.

Reading YAML Data (peek CLI)

Key insight: YAML fences are data, not executable code. Use --level sprout to get parsed YAML without trying to execute it.

import json
import subprocess

# Address format: slug:section.fence.key
address = 'my-node:my-section.yaml.some-key'

proc = subprocess.run(
    ['oculus', 'peek', address, '--level', 'sprout', '--format', 'json'],
    capture_output=True, text=True
)

if proc.returncode == 0:
    data = json.loads(proc.stdout.strip())

Address formats:

  • slug:section.yaml - entire YAML fence
  • slug:section.yaml.key - specific key in YAML
  • slug:section.yaml.nested.path - nested value

Cache levels:

Level Use Case
sprout YAML/JSON data fences (parsed, not executed)
clouds Python fences (executed, default)
seed Raw markdown, no processing
Writing Data (poke CLI)
import json
import subprocess

new_list = ["item1", "item2", "item3"]

# Note: VALUE is positional, not --value flag!
proc = subprocess.run(
    ['oculus', 'poke', f'my-node:my-section.yaml.my-key',
     json.dumps(new_list),
     '--context', 'Updating list from tool'],
    capture_output=True, text=True
)

Poke CLI syntax: oculus poke PATH VALUE --context CONTEXT

  • PATH can be slug:section.path format
  • VALUE is a positional argument (not --value flag!)
  • Always include --context for audit trail

See Also

Slots

North

slots:
- context: []
  slug: oculus-cli
- context:
  - Linking tutorial to CLI docs
  slug: oculus-cli

South

slots:
- context: []
  slug: cmd-fence
- context:
  - Linking frontend guide to backend guide
  slug: how-to-wire-stuffy-to-oculus
- context:
  - Linking whimsical example tool to tutorial
  slug: cosmic-fortune
- context:
  - Linking tutorial to main how-to guide
  slug: bolawa-20260107

Config

East

slots:
- context:
  - Practical demonstration of fences-as-functions
  slug: structural-isomorphism-thesis

Working With Data In Fences

↑ northoculus-cli
↓ southcmd-fencehow-to-wire-stuffy-to-oculuscosmic-fortunebolawa-20260107