init
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..65e85b4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+neo
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..d3cbf7b
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,21 @@
+module dodo.cloud/neo
+
+go 1.23.1
+
+require (
+	github.com/anthropics/anthropic-sdk-go v1.26.0
+	github.com/invopop/jsonschema v0.13.0
+)
+
+require (
+	github.com/bahlo/generic-list-go v0.2.0 // indirect
+	github.com/buger/jsonparser v1.1.1 // indirect
+	github.com/mailru/easyjson v0.7.7 // indirect
+	github.com/tidwall/gjson v1.18.0 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.1 // indirect
+	github.com/tidwall/sjson v1.2.5 // indirect
+	github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+	golang.org/x/sync v0.16.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b9a9c96
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,39 @@
+github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
+github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
+github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
+github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
+github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
+github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
+github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
+github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
+github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
+golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
+golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
+gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/hello.txt b/hello.txt
new file mode 100644
index 0000000..32f95c0
--- /dev/null
+++ b/hello.txt
@@ -0,0 +1 @@
+hi
\ No newline at end of file
diff --git a/length.txt b/length.txt
new file mode 100644
index 0000000..ce9bb2d
--- /dev/null
+++ b/length.txt
@@ -0,0 +1 @@
+The character count is 2
\ No newline at end of file
diff --git a/loop/CONVERSATION_RULES.md b/loop/CONVERSATION_RULES.md
new file mode 100644
index 0000000..421bc42
--- /dev/null
+++ b/loop/CONVERSATION_RULES.md
@@ -0,0 +1,81 @@
+# Conversation Rules
+To make converation concise, clear and avoid confusion let's first define precise set of rules everyone shall follow.
+
+## Dictionary
+1. Shall: Indicates a requirement that must be followed to claim conformity to the standard.
+2. Should: Indicates a recommendation.
+3. May: Indicates permission.
+4. Can: Indicates a possibility or capability.
+
+## Interaction
+1. Always be concise.
+2. You shall not use filler words.
+3. You shall not be emotional.
+4. Always sort clarification questions by their importance. Put the questions related to core business logic first, and peripheral questions last.
+5. When you have clarification questions respond only with just list of questions themselves.
+
+# Your Description
+You are a very experienced Senior Software-Engineer with following strengths:
+  1. You are good at always keeping the big picture in mind.
+  2. You understand very well maintenance costs of poorly designed and/or implemented software.
+  3. You always try to come up with a clean artchitecture.
+  4. You always split complex problem into small and managable sub-problems.
+  5. You are good at decoupling components from each other and coming up with clean separation of concerns between them.
+  6. You think of system components like Lego bricks:
+    1. Each component being very simple and doing one and only one thing very well.
+    2. You can solve complex problems by assembling such components.
+
+# Working Process
+Always follow following process when working on new task:
+  1. Task shall always be worked on as a tree of TODO items. See JSON schema bellow.
+  2. You shall use TODO item tree both for driving your thought process and actually resolving the issue.
+  3. At any given time you will be given ID of the TODO item to work on.
+  4. First you shall always assess complexity of the given TODO item. If small you shall tackle it right away, otherwise you shall split it into smaller sub-tasks and use `todo_item_add` tool to add sub-tasks as new TODO items.
+  5. You shall add clarification questions to TODO items, instead of giving me a free from question to answer.
+  6. If you have multiple clarification questions, you shall add them as a separate TODO items.
+  7. You shall mark done tasks as so using `todo_item_mark_done` tool.
+  8. You shall always assign someone to new TODO items. Assigned them to me (value `user`) if you need my input on the item, otherwise assign it to yourself (value `assistant`).
+  9. You shall split complex tasks in 4 phases: Research, Planning, Itemization, Implementation.
+  10. You shall execute these phases one after another.
+  11. Never move to the next phase without my explicit confirmation.
+  12. You shall not assign IDs to new TODO items. System will do that for us.
+  13. Each TODO item has a discussion field to represent back and forth communication between you and me regarding this TODO item. You shall use `todo_item_add_comment` tool to communicate with me.
+  14. You shall never start new discussion, instead create TODO items if you want to clarify something. You shall ever add comment to already started discussion.
+  15. When adding a comment to the TODO item discussion you shall assign item to the person you are expecting answer from.
+
+## Research
+Goal of this phase is to gather full context regarding the task at hand. At the end of this phase every requirement shall be identified and you shall not have any questions left. Start by analyzing current state of the project: read documentation, source code, unit tests. Make sure you understand project behaviour related to the given task.
+
+Make sure to clearly communicate with the user (me). Ask clarifying questions when necessary. If stuck ask for a direction.
+
+You may use tools to explore the current implementation.
+
+Summarize your findings. Think deep!
+
+## Planning
+Goal of this phase is to come up with the solution. Do not concentrate on minor details, but rather think in terms of high level abstractions.
+
+Consider multiple approaches on how to solve the given task, pick the best but present me with all approaches. Always keep in mind that code is a living thing which evolves. Changes made by you shall not slow down future developments.
+
+Always consider if refactoring current code base can make solving given problem easier. Take into account cost of the refactoring.
+
+Make sure to clearly communicate with the user (me). Ask clarifying questions when necessary. If stuck ask for a direction.
+
+It is possible that you get stuck at this phase, in which case you shall ask permission to go back to Research phase.
+
+## Itemization
+Goal of this phase is to come up with the detailed and itemized implementation plan. Which one can follow, like a todo list, and implement one action item after another in a row without going back to Research phase.
+
+Make sure to clearly communicate with the user (me). Ask clarifying questions when necessary. If stuck ask for a direction.
+
+It is possible that you get stuck at this phase, in which case you shall ask permission to go back to Planning or even Research phase.
+
+## Implementation
+Goal of this phase is to actually implement the chosen approach. Follow action items determined during Itemization phase and implement them sequentially.
+
+You shall only use tools to implement action items.
+
+During this phase you shall not communicate with the user. The only scenario when you may communicate with the user is when you get stuck and can not move forward without manual human intervention.
+
+## TODO JSON schema
+%s
diff --git a/loop/agent.go b/loop/agent.go
new file mode 100644
index 0000000..8e903a2
--- /dev/null
+++ b/loop/agent.go
@@ -0,0 +1,49 @@
+package loop
+
+import (
+	"fmt"
+	"time"
+)
+
+type Agent interface {
+	Run(todo *ToDo) error
+}
+
+type UserAgent struct {
+	pr PromptReader
+}
+
+func (a *UserAgent) Run(todo *ToDo) error {
+	for {
+		items := findActionableItems(todo, "user")
+		if len(items) == 0 {
+			time.Sleep(30 * time.Second)
+			continue
+		}
+		for _, i := range items {
+			fmt.Printf("YOU %s %s: ", i.ID, i.Title)
+			comment, err := a.pr.Read()
+			if err != nil {
+				return err
+			}
+			i.Discussion = append(i.Discussion, Comment{
+				Author:  "user",
+				Comment: comment,
+			})
+			i.AssignedTo = "assistant"
+		}
+	}
+}
+
+func findActionableItems(todo *ToDo, assignedTo string) []*ToDo {
+	for _, i := range todo.Items {
+		ret := findActionableItems(i, assignedTo)
+		if len(ret) > 0 {
+			return ret
+		}
+	}
+	if todo.AssignedTo == assignedTo && !todo.Done {
+		return []*ToDo{todo}
+	}
+	return nil
+}
diff --git a/loop/anthropic.go b/loop/anthropic.go
new file mode 100644
index 0000000..8ff5d6b
--- /dev/null
+++ b/loop/anthropic.go
@@ -0,0 +1,103 @@
+package loop
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"dodo.cloud/neo/tools"
+
+	"github.com/anthropics/anthropic-sdk-go"
+)
+
+type AnthropicAgent struct {
+	reg    tools.Registry
+	client anthropic.Client
+}
+
+func (a *AnthropicAgent) Run(todo *ToDo) error {
+	sp := fmt.Sprintf(systemPrompt, ToDoJSONSchema())
+	var toolParams []anthropic.ToolParam
+	for _, t := range a.reg.All() {
+		schema, err := GetToolSchema(t.InputSchema())
+		if err != nil {
+			return err
+		}
+		toolParams = append(toolParams, anthropic.ToolParam{
+			Name:        t.Name(),
+			Description: anthropic.String(t.Description()),
+			InputSchema: schema,
+		})
+	}
+	tools := make([]anthropic.ToolUnionParam, len(toolParams))
+	for i, toolParam := range toolParams {
+		tools[i] = anthropic.ToolUnionParam{OfTool: &toolParam}
+	}
+	k, err := json.MarshalIndent(todo, "", "\t")
+	if err != nil {
+		return err
+	}
+	for {
+		items := findActionableItems(todo, "assistant")
+		if len(items) == 0 {
+			time.Sleep(30 * time.Second)
+			continue
+		}
+		var itemIds []string
+		var messages []anthropic.MessageParam
+		for _, i := range items {
+			itemIds = append(itemIds, i.ID)
+		}
+		messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(string(k))))
+		messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(
+			fmt.Sprintf("Work on TODO item: %s", strings.Join(itemIds, ", ")))))
+		for {
+			resp, err := a.client.Messages.New(context.TODO(), anthropic.MessageNewParams{
+				MaxTokens: 10240,
+				System: []anthropic.TextBlockParam{
+					{Text: sp},
+				},
+				Messages: messages,
+				Model:    anthropic.ModelClaudeOpus4_6,
+				Tools:    tools,
+			})
+			if err != nil {
+				return err
+			}
+			fmt.Printf("--- STOP_REASON: %s\n", resp.StopReason)
+			messages = append(messages, resp.ToParam())
+
+			var toolResults []anthropic.ContentBlockParamUnion
+			for _, block := range resp.Content {
+				switch v := block.AsAny().(type) {
+				case anthropic.TextBlock:
+					fmt.Printf("AI: %s\n", v.Text)
+				case anthropic.ToolUseBlock:
+					t := a.reg.Get(v.Name)
+					if t == nil {
+						toolResults = append(toolResults, anthropic.NewToolResultBlock(v.ID, fmt.Sprintf("unknown tool %q", v.Name), true))
+						continue
+					}
+					args := v.JSON.Input.Raw()
+					fmt.Printf("CALLING TOOL: %s %s\n", v.Name, args)
+					out, err := t.Call(string(args))
+					if err != nil {
+						fmt.Printf("ERR: %s\n", err.Error())
+						toolResults = append(toolResults, anthropic.NewToolResultBlock(v.ID, err.Error(), true))
+					} else {
+						toolResults = append(toolResults, anthropic.NewToolResultBlock(v.ID, out, false))
+					}
+				}
+			}
+			if len(toolResults) == 0 {
+				break
+			}
+			messages = append(messages, anthropic.NewUserMessage(toolResults...))
+		}
+		for _, i := range items {
+			i.AssignedTo = "user"
+		}
+	}
+}
diff --git a/loop/client.go b/loop/client.go
new file mode 100644
index 0000000..a3e4240
--- /dev/null
+++ b/loop/client.go
@@ -0,0 +1,18 @@
+package loop
+
+import (
+	"github.com/anthropics/anthropic-sdk-go"
+	"github.com/anthropics/anthropic-sdk-go/option"
+)
+
+type Client struct {
+	c anthropic.Client
+}
+
+func NewClient(key string) *Client {
+	return &Client{
+		c: anthropic.NewClient(
+			option.WithAPIKey(key),
+		),
+	}
+}
diff --git a/loop/loop.go b/loop/loop.go
new file mode 100644
index 0000000..6cfa111
--- /dev/null
+++ b/loop/loop.go
@@ -0,0 +1,209 @@
+package loop
+
+import (
+	_ "embed"
+	"encoding/json"
+	"fmt"
+	"sync"
+
+	"dodo.cloud/neo/tools"
+
+	"github.com/anthropics/anthropic-sdk-go"
+	"github.com/invopop/jsonschema"
+)
+
+//go:embed CONVERSATION_RULES.md
+var systemPrompt string
+
+type Message struct {
+	Author   string `json:"author"`
+	Contents string `json:"contents"`
+	Done     bool   `json:"done"`
+}
+
+type Conversation struct {
+	Messages []Message `json:"message"`
+}
+
+var todo *ToDo
+
+func Run(pr PromptReader, client *Client, tools tools.Registry) error {
+	RegisterToDoTools(tools)
+	fmt.Printf("YOU: ")
+	prompt, err := pr.Read()
+	if err != nil {
+		return err
+	}
+	todo = &ToDo{}
+	todo.ID = "1"
+	todo.Title = prompt
+	todo.AssignedTo = "assistant"
+	agents := []Agent{
+		&UserAgent{pr},
+		&AnthropicAgent{tools, client.c},
+	}
+	var wg sync.WaitGroup
+	for _, a := range agents {
+		wg.Add(1)
+		go func() {
+			if err := a.Run(todo); err != nil {
+				panic(err)
+			}
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+	return nil
+}
+
+// func Loop(todo *ToDo, item *ToDo, pr PromptReader, client *Client, reg tools.Registry) error {
+// 	messages := []anthropic.MessageParam{
+// 		anthropic.NewUserMessage(anthropic.NewTextBlock(string(k))),
+// 		anthropic.NewUserMessage(anthropic.NewTextBlock(fmt.Sprintf("Work on TODO item: %s", item.ID))),
+// 	}
+// 	for {
+// 		fmt.Println(todo.String())
+// 		if item.AssignedTo == "user" {
+// 			fmt.Printf("YOU %s: ", item.ID)
+// 			prompt, err := pr.Read()
+// 			if err != nil {
+// 				return err
+// 			}
+// 			item.Discussion = append(item.Discussion, Comment{
+// 				Author:  "user",
+// 				Comment: prompt,
+// 			})
+// 			item.AssignedTo = "assistant"
+// 			messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)))
+// 		} else {
+// 			if len(messages) == 0 {
+// 				messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(fmt.Sprintf("Work on TODO item with id"))))
+// 			}
+// 		}
+
+// 	}
+// }
+
+func pickToDoItem(todo *ToDo) *ToDo {
+	if todo.Done {
+		return nil
+	}
+	for _, i := range todo.Items {
+		if ret := pickToDoItem(i); ret != nil {
+			return ret
+		}
+	}
+	return todo
+}
+
+func findItemByID(todo *ToDo, id string) *ToDo {
+	if todo.ID == id {
+		return todo
+	}
+	for _, i := range todo.Items {
+		if ret := findItemByID(i, id); ret != nil {
+			return ret
+		}
+	}
+	return nil
+}
+
+type ToDoItem struct {
+	ParentID    string `json:"parentId"`
+	Title       string `json:"title"`
+	Description string `json:"description"`
+	AssignedTo  string `json:"assignedTo"`
+}
+
+type ToDoAddItemArgs struct {
+	Items []ToDoItem `json:"items"`
+}
+
+func ToDoAddItem(args ToDoAddItemArgs) (string, error) {
+	for _, td := range args.Items {
+		item := findItemByID(todo, td.ParentID)
+		if item == nil {
+			return "error", fmt.Errorf("TODO item with given id not found: %s", td.ParentID)
+		}
+		id := fmt.Sprintf("%s.%d", item.ID, len(item.Items)+1)
+		item.Items = append(item.Items, &ToDo{
+			ID:          id,
+			Title:       td.Title,
+			Description: td.Description,
+			AssignedTo:  td.AssignedTo,
+		})
+	}
+	return "done", nil
+}
+
+type ToDoMarkItemDoneArgs struct {
+	ID string `json:"id"`
+}
+
+func ToDoMarkItemDone(args ToDoMarkItemDoneArgs) (string, error) {
+	item := findItemByID(todo, args.ID)
+	if item == nil {
+		return "error", fmt.Errorf("TODO item with given id not found: %s", args.ID)
+	}
+	item.Done = true
+	return "done", nil
+}
+
+type ToDoItemAddCommentArgs struct {
+	ID       string `json:"id"`
+	Comment  string `json:"comment"`
+	AssignTo string `json:"assignTo"`
+}
+
+func ToDoItemAddComment(args ToDoItemAddCommentArgs) (string, error) {
+	item := findItemByID(todo, args.ID)
+	if item == nil {
+		return "error", fmt.Errorf("TODO item with given id not found: %s", args.ID)
+	}
+	if len(item.Discussion) == 0 {
+		return "error", fmt.Errorf("You shall never initiate a discussion, if you want to clarify something create a TODO item for it.")
+	}
+	item.Discussion = append(item.Discussion, Comment{
+		Author:  "assistant",
+		Comment: args.Comment,
+	})
+	item.AssignedTo = args.AssignTo
+	return "done", nil
+}
+
+func RegisterToDoTools(reg tools.Registry) {
+	reg.Add(tools.NewFuncTool("todo_item_add", ToDoAddItem, "Add new ToDo item."))
+	reg.Add(tools.NewFuncTool("todo_item_mark_done", ToDoMarkItemDone, "Marks ToDo item with given ID as done."))
+	reg.Add(tools.NewFuncTool("todo_item_add_comment", ToDoItemAddComment, "Adds discussion comment to given ToDo item"))
+}
+
+func GetToolSchema(schema *jsonschema.Schema) (anthropic.ToolInputSchemaParam, error) {
+	schemaBytes, err := json.Marshal(schema)
+	if err != nil {
+		return anthropic.ToolInputSchemaParam{}, err
+	}
+	var schemaMap map[string]any
+	if err := json.Unmarshal(schemaBytes, &schemaMap); err != nil {
+		return anthropic.ToolInputSchemaParam{}, err
+	}
+
+	inputSchema, err := parseSchemaMap(schemaMap)
+	if err != nil {
+		return anthropic.ToolInputSchemaParam{}, err
+	}
+	return inputSchema, nil
+}
+
+func parseSchemaMap(s map[string]any) (anthropic.ToolInputSchemaParam, error) {
+	bytes, err := json.Marshal(s)
+	if err != nil {
+		return anthropic.ToolInputSchemaParam{}, fmt.Errorf("failed to marshal schema: %w", err)
+	}
+
+	var schema anthropic.ToolInputSchemaParam
+	if err := json.Unmarshal(bytes, &schema); err != nil {
+		return anthropic.ToolInputSchemaParam{}, fmt.Errorf("failed to unmarshal schema: %w", err)
+	}
+
+	return schema, nil
+}
diff --git a/loop/prompt.go b/loop/prompt.go
new file mode 100644
index 0000000..c889247
--- /dev/null
+++ b/loop/prompt.go
@@ -0,0 +1,28 @@
+package loop
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+)
+
+type PromptReader interface {
+	Read() (string, error)
+}
+
+type IOReaderPromptReader struct {
+	s *bufio.Scanner
+}
+
+func NewIOReaderPromptReader(r io.Reader) *IOReaderPromptReader {
+	return &IOReaderPromptReader{
+		s: bufio.NewScanner(r),
+	}
+}
+
+func (r IOReaderPromptReader) Read() (string, error) {
+	if !r.s.Scan() {
+		return "", fmt.Errorf("done")
+	}
+	return r.s.Text(), nil
+}
diff --git a/loop/todo.go b/loop/todo.go
new file mode 100644
index 0000000..e639857
--- /dev/null
+++ b/loop/todo.go
@@ -0,0 +1,61 @@
+package loop
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+
+	"github.com/invopop/jsonschema"
+)
+
+type Comment struct {
+	Author  string `json:"author"`
+	Comment string `json:"comment"`
+}
+
+func (c Comment) String() string {
+	return fmt.Sprintf("%s: %s", c.Author, c.Comment)
+}
+
+type ToDo struct {
+	ID          string    `json:"id"`
+	Title       string    `json:"title"`
+	Description string    `json:"description"`
+	Items       []*ToDo   `json:"items"`
+	Done        bool      `json:"done"`
+	AssignedTo  string    `json:"assignedTo"`
+	Discussion  []Comment `json:"discussion"`
+	lock        sync.Locker
+}
+
+const tmpl = `%s: %s
+%s
+%s
+%s`
+
+func (t ToDo) String() string {
+	var comments []string
+	for _, c := range t.Discussion {
+		comments = append(comments, fmt.Sprintf("\t - %s", c.String()))
+	}
+	var items []string
+	for _, i := range t.Items {
+		items = append(items, fmt.Sprintf("\t%s", i.String()))
+	}
+	return fmt.Sprintf(tmpl, t.ID, t.Title, t.Description, strings.Join(comments, "\n"), strings.Join(items, "\n"))
+}
+
+func ToDoJSONSchema() string {
+	reflector := jsonschema.Reflector{
+		AllowAdditionalProperties:  false,
+		RequiredFromJSONSchemaTags: true,
+		DoNotReference:             false,
+	}
+	var v ToDo
+	s := reflector.Reflect(v)
+	b, err := s.MarshalJSON()
+	if err != nil {
+		panic(err)
+	}
+	return string(b)
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3e7c7e4
--- /dev/null
+++ b/main.go
@@ -0,0 +1,24 @@
+package main
+
+import (
+	"os"
+
+	"dodo.cloud/neo/loop"
+	"dodo.cloud/neo/tools"
+)
+
+func check(err error) {
+	if err != nil {
+		panic(err)
+	}
+}
+
+func main() {
+	reg := tools.NewInMemoryRegistry()
+	tools.Register(reg)
+	client := loop.NewClient("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")
+	pr := loop.NewIOReaderPromptReader(os.Stdin)
+	if err := loop.Run(pr, client, reg); err != nil {
+		panic(err)
+	}
+}
diff --git a/tools/file.go b/tools/file.go
new file mode 100644
index 0000000..815c2eb
--- /dev/null
+++ b/tools/file.go
@@ -0,0 +1,65 @@
+package tools
+
+import (
+	"os"
+)
+
+type FileReadArgs struct {
+	Path string `json:"path"`
+}
+
+func FileRead(args FileReadArgs) (string, error) {
+	if b, err := os.ReadFile(args.Path); err != nil {
+		return "", err
+	} else {
+		return string(b), nil
+	}
+}
+
+type FileWriteArgs struct {
+	Path     string `json:"path"`
+	Contents string `json:"contents"`
+}
+
+type FileWriteResult struct {
+}
+
+func FileWrite(args FileWriteArgs) (string, error) {
+	if err := os.WriteFile(args.Path, []byte(args.Contents), 0666); err != nil {
+		return "error", err
+	} else {
+		return "done", nil
+	}
+}
+
+type DirListArgs struct {
+	Name string `json:"name"`
+}
+
+type DirEntry struct {
+	Name  string `json:"name"`
+	IsDir bool   `json:"is_dir"`
+}
+
+type DirListResult struct {
+	Entries []DirEntry `json:"entries"`
+}
+
+func DirList(args DirListArgs) (DirListResult, error) {
+	dir := "."
+	if args.Name != "" {
+		dir = args.Name
+	}
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return DirListResult{}, err
+	}
+	var ret DirListResult
+	for _, e := range entries {
+		ret.Entries = append(ret.Entries, DirEntry{
+			Name:  e.Name(),
+			IsDir: e.IsDir(),
+		})
+	}
+	return ret, nil
+}
diff --git a/tools/func.go b/tools/func.go
new file mode 100644
index 0000000..9ad2add
--- /dev/null
+++ b/tools/func.go
@@ -0,0 +1,66 @@
+package tools
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/invopop/jsonschema"
+)
+
+type FuncTool[I any, O any] struct {
+	name   string
+	desc   string
+	schema *jsonschema.Schema
+	fn     func(args I) (O, error)
+}
+
+func NewFuncTool[I any, O any](name string, fn func(args I) (O, error), desc string) *FuncTool[I, O] {
+	return &FuncTool[I, O]{
+		name,
+		fmt.Sprintf("%s\nResult schema: %s", desc, GenerateSchema[O]()),
+		GenerateSchema[I](),
+		fn,
+	}
+}
+
+func (ft *FuncTool[I, O]) Name() string {
+	return ft.name
+}
+
+func (ft *FuncTool[I, O]) Description() string {
+	return ft.desc
+}
+
+func (ft *FuncTool[I, O]) InputSchema() *jsonschema.Schema {
+	return ft.schema
+}
+
+func (ft *FuncTool[I, O]) Call(inp string) (string, error) {
+	var args I
+	if err := json.NewDecoder(strings.NewReader(inp)).Decode(&args); err != nil {
+		return "", err
+	}
+	out, err := ft.fn(args)
+	if err != nil {
+		return "", err
+	}
+	var resp strings.Builder
+	if err := json.NewEncoder(&resp).Encode(out); err != nil {
+		return "", err
+	}
+	ret := resp.String()
+	fmt.Printf("$$$ %s\n", ret)
+	return ret, nil
+
+}
+
+func GenerateSchema[T any]() *jsonschema.Schema {
+	reflector := jsonschema.Reflector{
+		AllowAdditionalProperties:  false,
+		RequiredFromJSONSchemaTags: true,
+		DoNotReference:             true,
+	}
+	var v T
+	return reflector.Reflect(v)
+}
diff --git a/tools/init.go b/tools/init.go
new file mode 100644
index 0000000..7c2ec4d
--- /dev/null
+++ b/tools/init.go
@@ -0,0 +1,8 @@
+package tools
+
+func Register(reg Registry) error {
+	reg.Add(NewFuncTool("file_read", FileRead, "Reads contents of the given file."))
+	reg.Add(NewFuncTool("file_write", FileWrite, "Writes given contents to a file."))
+	reg.Add(NewFuncTool("dir_list", DirList, "Reads directory with given name, returning all its entries."))
+	return nil
+}
diff --git a/tools/tool.go b/tools/tool.go
new file mode 100644
index 0000000..c99fa56
--- /dev/null
+++ b/tools/tool.go
@@ -0,0 +1,44 @@
+package tools
+
+import (
+	"github.com/invopop/jsonschema"
+)
+
+type Tool interface {
+	Name() string
+	Description() string
+	InputSchema() *jsonschema.Schema
+	Call(inp string) (string, error)
+}
+
+type Registry interface {
+	All() []Tool
+	Add(tool Tool)
+	Get(name string) Tool
+}
+
+type InMemoryRegistry struct {
+	tools map[string]Tool
+}
+
+func NewInMemoryRegistry() *InMemoryRegistry {
+	return &InMemoryRegistry{
+		make(map[string]Tool),
+	}
+}
+
+func (r *InMemoryRegistry) Add(tool Tool) {
+	r.tools[tool.Name()] = tool
+}
+
+func (r *InMemoryRegistry) All() []Tool {
+	var ret []Tool
+	for _, t := range r.tools {
+		ret = append(ret, t)
+	}
+	return ret
+}
+
+func (r *InMemoryRegistry) Get(name string) Tool {
+	return r.tools[name]
+}